fluxmail 0.2.1 → 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 -51
- 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 +13 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +881 -572
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +105 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +128 -13
- 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 +332 -45
- 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 +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -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 +4 -2
- package/dist/mcp/buildServer.d.ts.map +1 -1
- package/dist/mcp/buildServer.js +286 -230
- package/dist/mcp/buildServer.js.map +1 -1
- package/dist/permissions.d.ts +30 -0
- package/dist/permissions.d.ts.map +1 -0
- package/dist/permissions.js +119 -0
- package/dist/permissions.js.map +1 -0
- package/dist/service/emailService.d.ts +16 -9
- package/dist/service/emailService.d.ts.map +1 -1
- package/dist/service/emailService.js +44 -19
- 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 +28 -5
- package/dist/storage/apiKeys.d.ts.map +1 -1
- package/dist/storage/apiKeys.js +153 -10
- package/dist/storage/apiKeys.js.map +1 -1
- package/dist/storage/db.d.ts +524 -0
- package/dist/storage/db.d.ts.map +1 -1
- package/dist/storage/db.js +184 -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/dist/telemetry.d.ts +9 -2
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +117 -54
- package/dist/telemetry.js.map +1 -1
- package/package.json +7 -5
|
@@ -0,0 +1,1130 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
|
|
3
|
+
import { HTTPException } from 'hono/http-exception';
|
|
4
|
+
import { EmailError, isEmailError } from '@fluxmail/core';
|
|
5
|
+
import { authenticateApiKey } from '../storage/apiKeys.js';
|
|
6
|
+
import { completeIdempotencyKey, reserveIdempotencyKey } from '../storage/restIdempotency.js';
|
|
7
|
+
import { FULL_PERMISSION_POLICY, hasCapability } from '../permissions.js';
|
|
8
|
+
import { VERSION } from '../version.js';
|
|
9
|
+
import { administrationUsesHttps, registerAdminRoutes, requestBodyExceedsLimit } from './admin.js';
|
|
10
|
+
import { recordAdminAuditEvent } from '../storage/adminAudit.js';
|
|
11
|
+
const id = z.string().trim().min(1);
|
|
12
|
+
const accountId = id.openapi({ example: 'acct_123' });
|
|
13
|
+
const messageId = id.openapi({ example: 'msg_123' });
|
|
14
|
+
const threadId = id.openapi({ example: 'thread_123' });
|
|
15
|
+
const draftId = id.openapi({ example: 'draft_123' });
|
|
16
|
+
const scheduleId = id.openapi({ example: 'schedule_123' });
|
|
17
|
+
const attachmentId = id.openapi({ example: 'attachment_123' });
|
|
18
|
+
const isoDate = z
|
|
19
|
+
.string()
|
|
20
|
+
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Expected an ISO date in YYYY-MM-DD format')
|
|
21
|
+
.refine((value) => {
|
|
22
|
+
const parsed = Date.parse(`${value}T00:00:00Z`);
|
|
23
|
+
return !Number.isNaN(parsed) && new Date(parsed).toISOString().slice(0, 10) === value;
|
|
24
|
+
}, 'Expected a valid calendar date')
|
|
25
|
+
.openapi({ format: 'date' });
|
|
26
|
+
const base64Content = z
|
|
27
|
+
.string()
|
|
28
|
+
.regex(/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/, 'Expected base64 content')
|
|
29
|
+
.openapi({ format: 'byte' });
|
|
30
|
+
const accountParams = z.object({ accountId }).strict();
|
|
31
|
+
const messageParams = z.object({ accountId, messageId }).strict();
|
|
32
|
+
const threadParams = z.object({ accountId, threadId }).strict();
|
|
33
|
+
const draftParams = z.object({ accountId, draftId }).strict();
|
|
34
|
+
const scheduleParams = z.object({ accountId, scheduleId }).strict();
|
|
35
|
+
const attachmentParams = z.object({ accountId, messageId, attachmentId }).strict();
|
|
36
|
+
const EmailAddressSchema = z
|
|
37
|
+
.object({ email: z.string().email(), name: z.string().min(1).optional() })
|
|
38
|
+
.strict()
|
|
39
|
+
.openapi('EmailAddress');
|
|
40
|
+
const MessageBodySchema = z
|
|
41
|
+
.object({ text: z.string().optional(), html: z.string().optional() })
|
|
42
|
+
.strict()
|
|
43
|
+
.openapi('MessageBody');
|
|
44
|
+
const AttachmentInputSchema = z
|
|
45
|
+
.object({
|
|
46
|
+
filename: z.string().min(1),
|
|
47
|
+
mimeType: z.string().min(1),
|
|
48
|
+
content: base64Content.describe('Base64-encoded content'),
|
|
49
|
+
contentId: z.string().min(1).optional(),
|
|
50
|
+
disposition: z.enum(['inline', 'attachment']).optional(),
|
|
51
|
+
})
|
|
52
|
+
.strict()
|
|
53
|
+
.openapi('AttachmentInput');
|
|
54
|
+
const AttachmentMetaSchema = z
|
|
55
|
+
.object({
|
|
56
|
+
id: z.string(),
|
|
57
|
+
filename: z.string(),
|
|
58
|
+
mimeType: z.string(),
|
|
59
|
+
sizeBytes: z.number().int().nonnegative(),
|
|
60
|
+
contentId: z.string().optional(),
|
|
61
|
+
disposition: z.enum(['inline', 'attachment']).optional(),
|
|
62
|
+
})
|
|
63
|
+
.strict()
|
|
64
|
+
.openapi('AttachmentMeta');
|
|
65
|
+
const FolderSchema = z
|
|
66
|
+
.object({
|
|
67
|
+
id: z.string(),
|
|
68
|
+
name: z.string(),
|
|
69
|
+
role: z.enum(['inbox', 'sent', 'drafts', 'trash', 'spam', 'archive', 'starred', 'all']).optional(),
|
|
70
|
+
roleSource: z.enum(['user', 'extension', 'name']).optional(),
|
|
71
|
+
unreadCount: z.number().int().nonnegative().optional(),
|
|
72
|
+
})
|
|
73
|
+
.strict()
|
|
74
|
+
.openapi('Folder');
|
|
75
|
+
const MessageSchema = z
|
|
76
|
+
.object({
|
|
77
|
+
id: z.string(),
|
|
78
|
+
threadId: z.string(),
|
|
79
|
+
accountId: z.string(),
|
|
80
|
+
draftId: z.string().optional(),
|
|
81
|
+
folder: FolderSchema.optional(),
|
|
82
|
+
labels: z.array(z.string()).optional(),
|
|
83
|
+
from: EmailAddressSchema.optional(),
|
|
84
|
+
to: z.array(EmailAddressSchema),
|
|
85
|
+
cc: z.array(EmailAddressSchema).optional(),
|
|
86
|
+
bcc: z.array(EmailAddressSchema).optional(),
|
|
87
|
+
replyTo: z.array(EmailAddressSchema).optional(),
|
|
88
|
+
subject: z.string(),
|
|
89
|
+
date: z.string(),
|
|
90
|
+
snippet: z.string().optional(),
|
|
91
|
+
body: MessageBodySchema.optional(),
|
|
92
|
+
attachments: z.array(AttachmentMetaSchema).optional(),
|
|
93
|
+
flags: z.object({ read: z.boolean(), starred: z.boolean(), draft: z.boolean() }).strict(),
|
|
94
|
+
headers: z.record(z.string()).optional(),
|
|
95
|
+
})
|
|
96
|
+
.strict()
|
|
97
|
+
.openapi('Message');
|
|
98
|
+
const ThreadSchema = z
|
|
99
|
+
.object({ id: z.string(), subject: z.string(), messages: z.array(MessageSchema) })
|
|
100
|
+
.strict()
|
|
101
|
+
.openapi('Thread');
|
|
102
|
+
const AccountSchema = z
|
|
103
|
+
.object({
|
|
104
|
+
id: z.string(),
|
|
105
|
+
provider: z.enum(['gmail', 'outlook', 'imap']),
|
|
106
|
+
email: z.string(),
|
|
107
|
+
displayName: z.string().optional(),
|
|
108
|
+
status: z.enum(['active', 'auth_error', 'disabled']),
|
|
109
|
+
capabilities: z
|
|
110
|
+
.object({
|
|
111
|
+
labels: z.boolean(),
|
|
112
|
+
serverThreads: z.boolean(),
|
|
113
|
+
serverSearch: z.enum(['rich', 'basic']),
|
|
114
|
+
snippets: z.boolean(),
|
|
115
|
+
})
|
|
116
|
+
.strict(),
|
|
117
|
+
ownerId: z.string().optional(),
|
|
118
|
+
sharingMode: z.enum(['private', 'all', 'selected']),
|
|
119
|
+
sharedMemberIds: z.array(z.string()),
|
|
120
|
+
memberId: z.string().optional(),
|
|
121
|
+
})
|
|
122
|
+
.strict()
|
|
123
|
+
.openapi('Account');
|
|
124
|
+
const ScheduledSendSchema = z
|
|
125
|
+
.object({
|
|
126
|
+
scheduleId: z.string(),
|
|
127
|
+
accountId: z.string(),
|
|
128
|
+
draftId: z.string(),
|
|
129
|
+
sendAt: z.string(),
|
|
130
|
+
status: z.enum(['pending', 'sending', 'sent', 'failed', 'canceled']),
|
|
131
|
+
attempts: z.number().int().nonnegative(),
|
|
132
|
+
subject: z.string().optional(),
|
|
133
|
+
to: z.string().optional(),
|
|
134
|
+
lastError: z.string().optional(),
|
|
135
|
+
sentMessageId: z.string().optional(),
|
|
136
|
+
sentThreadId: z.string().optional(),
|
|
137
|
+
})
|
|
138
|
+
.strict()
|
|
139
|
+
.openapi('ScheduledSend');
|
|
140
|
+
const SendResultSchema = z
|
|
141
|
+
.object({ id: z.string(), threadId: z.string(), warnings: z.array(z.string()).optional() })
|
|
142
|
+
.strict()
|
|
143
|
+
.openapi('SendResult');
|
|
144
|
+
const ErrorSchema = z
|
|
145
|
+
.object({
|
|
146
|
+
error: z.object({ code: z.string(), message: z.string(), data: z.record(z.unknown()).optional() }).strict(),
|
|
147
|
+
})
|
|
148
|
+
.strict()
|
|
149
|
+
.openapi('RestError');
|
|
150
|
+
function dataEnvelope(schema) {
|
|
151
|
+
return z.object({ data: schema, warnings: z.array(z.string()).optional() }).strict();
|
|
152
|
+
}
|
|
153
|
+
function pagedEnvelope(schema) {
|
|
154
|
+
return z
|
|
155
|
+
.object({
|
|
156
|
+
data: z.array(schema),
|
|
157
|
+
meta: z.object({ nextPageToken: z.string().optional() }).strict(),
|
|
158
|
+
warnings: z.array(z.string()).optional(),
|
|
159
|
+
})
|
|
160
|
+
.strict();
|
|
161
|
+
}
|
|
162
|
+
const errorResponses = {
|
|
163
|
+
400: { content: { 'application/json': { schema: ErrorSchema } }, description: 'Invalid request' },
|
|
164
|
+
401: { content: { 'application/json': { schema: ErrorSchema } }, description: 'Authentication required' },
|
|
165
|
+
403: { content: { 'application/json': { schema: ErrorSchema } }, description: 'Permission or plan denied' },
|
|
166
|
+
404: { content: { 'application/json': { schema: ErrorSchema } }, description: 'Resource not found' },
|
|
167
|
+
409: { content: { 'application/json': { schema: ErrorSchema } }, description: 'Request conflict' },
|
|
168
|
+
422: { content: { 'application/json': { schema: ErrorSchema } }, description: 'Unsupported capability' },
|
|
169
|
+
429: { content: { 'application/json': { schema: ErrorSchema } }, description: 'Provider rate limit' },
|
|
170
|
+
500: { content: { 'application/json': { schema: ErrorSchema } }, description: 'Internal error' },
|
|
171
|
+
503: { content: { 'application/json': { schema: ErrorSchema } }, description: 'Provider unavailable' },
|
|
172
|
+
};
|
|
173
|
+
const protectedRoute = {
|
|
174
|
+
security: [{ bearerAuth: [] }],
|
|
175
|
+
};
|
|
176
|
+
const idempotencyHeaders = z.object({
|
|
177
|
+
'Idempotency-Key': z
|
|
178
|
+
.string()
|
|
179
|
+
.min(1)
|
|
180
|
+
.max(255)
|
|
181
|
+
.regex(/^[\x21-\x7e]+$/, 'Idempotency-Key must contain printable ASCII without spaces')
|
|
182
|
+
.describe('A unique key for one intended delivery. Reuse it when retrying the same request.'),
|
|
183
|
+
});
|
|
184
|
+
const draftShape = {
|
|
185
|
+
to: z.array(EmailAddressSchema).optional(),
|
|
186
|
+
cc: z.array(EmailAddressSchema).optional(),
|
|
187
|
+
bcc: z.array(EmailAddressSchema).optional(),
|
|
188
|
+
subject: z.string().optional(),
|
|
189
|
+
body: MessageBodySchema,
|
|
190
|
+
replyToMessageId: messageId.optional(),
|
|
191
|
+
replyAll: z.boolean().optional().describe('Requires replyToMessageId when true.'),
|
|
192
|
+
attachments: z.array(AttachmentInputSchema).optional(),
|
|
193
|
+
};
|
|
194
|
+
function requireReplyTarget(value, ctx) {
|
|
195
|
+
if (value.replyAll && !value.replyToMessageId) {
|
|
196
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['replyAll'], message: 'replyAll requires replyToMessageId' });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const DraftRequestSchema = z.object(draftShape).strict().superRefine(requireReplyTarget).openapi('DraftRequest');
|
|
200
|
+
const SendContentSchema = z
|
|
201
|
+
.object({ ...draftShape, sendAt: z.string().datetime({ offset: true }).optional() })
|
|
202
|
+
.strict()
|
|
203
|
+
.superRefine(requireReplyTarget);
|
|
204
|
+
const SendDraftSchema = z.object({ draftId, sendAt: z.string().datetime({ offset: true }).optional() }).strict();
|
|
205
|
+
const SendRequestSchema = z.union([SendDraftSchema, SendContentSchema]).openapi('SendRequest');
|
|
206
|
+
const ForwardRequestSchema = z
|
|
207
|
+
.object({
|
|
208
|
+
to: z.array(EmailAddressSchema).min(1),
|
|
209
|
+
cc: z.array(EmailAddressSchema).optional(),
|
|
210
|
+
comment: z.string().optional(),
|
|
211
|
+
includeAttachments: z
|
|
212
|
+
.boolean()
|
|
213
|
+
.optional()
|
|
214
|
+
.openapi({ description: 'Include attachments from the original message. Defaults to true.', default: true }),
|
|
215
|
+
})
|
|
216
|
+
.strict()
|
|
217
|
+
.openapi('ForwardRequest');
|
|
218
|
+
const modifyActionNames = [
|
|
219
|
+
'markRead',
|
|
220
|
+
'markUnread',
|
|
221
|
+
'star',
|
|
222
|
+
'unstar',
|
|
223
|
+
'archive',
|
|
224
|
+
'trash',
|
|
225
|
+
'untrash',
|
|
226
|
+
'delete',
|
|
227
|
+
'move',
|
|
228
|
+
'addLabels',
|
|
229
|
+
'removeLabels',
|
|
230
|
+
];
|
|
231
|
+
const ModifyRequestSchema = z
|
|
232
|
+
.object({
|
|
233
|
+
messageIds: z.array(messageId).min(1),
|
|
234
|
+
action: z.enum(modifyActionNames),
|
|
235
|
+
folder: z
|
|
236
|
+
.string()
|
|
237
|
+
.min(1)
|
|
238
|
+
.optional()
|
|
239
|
+
.describe('Required when action is move. Use archive or trash instead of moving to those folders.'),
|
|
240
|
+
labels: z
|
|
241
|
+
.array(z.string().min(1))
|
|
242
|
+
.max(100)
|
|
243
|
+
.optional()
|
|
244
|
+
.describe('Required when action is addLabels or removeLabels. Change system labels with dedicated actions.'),
|
|
245
|
+
})
|
|
246
|
+
.strict()
|
|
247
|
+
.superRefine((value, ctx) => {
|
|
248
|
+
if (value.action === 'move' && !value.folder) {
|
|
249
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['folder'], message: 'action=move requires folder' });
|
|
250
|
+
}
|
|
251
|
+
if ((value.action === 'addLabels' || value.action === 'removeLabels') && !value.labels?.length) {
|
|
252
|
+
ctx.addIssue({
|
|
253
|
+
code: z.ZodIssueCode.custom,
|
|
254
|
+
path: ['labels'],
|
|
255
|
+
message: `action=${value.action} requires labels`,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
})
|
|
259
|
+
.openapi('ModifyMessagesRequest');
|
|
260
|
+
const messageQuerySchema = z
|
|
261
|
+
.object({
|
|
262
|
+
folder: z
|
|
263
|
+
.string()
|
|
264
|
+
.min(1)
|
|
265
|
+
.optional()
|
|
266
|
+
.describe("Folder role (inbox, sent, drafts, trash, spam, starred, archive, all) or a label/folder name. Use all or omit this field to search all mail except Spam and Trash. An IMAP server's \\All mailbox may use different rules."),
|
|
267
|
+
text: z.string().optional(),
|
|
268
|
+
from: z.string().optional(),
|
|
269
|
+
to: z.string().optional(),
|
|
270
|
+
subject: z.string().optional(),
|
|
271
|
+
unreadOnly: z.enum(['true', 'false']).optional(),
|
|
272
|
+
starredOnly: z.enum(['true', 'false']).optional(),
|
|
273
|
+
hasAttachment: z.enum(['true', 'false']).optional(),
|
|
274
|
+
after: isoDate.optional(),
|
|
275
|
+
before: isoDate.optional(),
|
|
276
|
+
rawProviderQuery: z.string().optional(),
|
|
277
|
+
pageSize: z
|
|
278
|
+
.string()
|
|
279
|
+
.regex(/^(?:[1-9]|[1-9][0-9]|100)$/)
|
|
280
|
+
.optional(),
|
|
281
|
+
pageToken: z.string().min(1).optional(),
|
|
282
|
+
})
|
|
283
|
+
.strict();
|
|
284
|
+
const MODIFY_CAPABILITIES = {
|
|
285
|
+
markRead: 'mail.organize',
|
|
286
|
+
markUnread: 'mail.organize',
|
|
287
|
+
star: 'mail.organize',
|
|
288
|
+
unstar: 'mail.organize',
|
|
289
|
+
archive: 'mail.organize',
|
|
290
|
+
trash: 'mail.trash',
|
|
291
|
+
untrash: 'mail.trash',
|
|
292
|
+
delete: 'mail.delete',
|
|
293
|
+
move: 'mail.organize',
|
|
294
|
+
addLabels: 'mail.organize',
|
|
295
|
+
removeLabels: 'mail.organize',
|
|
296
|
+
};
|
|
297
|
+
const PROTECTED_MOVE_DESTINATIONS = new Set(['archive', 'trash']);
|
|
298
|
+
const SYSTEM_LABELS = new Set(['inbox', 'sent', 'draft', 'drafts', 'trash', 'spam', 'starred', 'unread', 'important']);
|
|
299
|
+
class RestFailure extends Error {
|
|
300
|
+
code;
|
|
301
|
+
status;
|
|
302
|
+
data;
|
|
303
|
+
retryAfter;
|
|
304
|
+
constructor(code, message, status, data, retryAfter) {
|
|
305
|
+
super(message);
|
|
306
|
+
this.code = code;
|
|
307
|
+
this.status = status;
|
|
308
|
+
this.data = data;
|
|
309
|
+
this.retryAfter = retryAfter;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
function apiFailure(err) {
|
|
313
|
+
if (err instanceof RestFailure) {
|
|
314
|
+
return {
|
|
315
|
+
status: err.status,
|
|
316
|
+
payload: { error: { code: err.code, message: err.message, ...(err.data ? { data: err.data } : {}) } },
|
|
317
|
+
...(err.retryAfter ? { retryAfter: err.retryAfter } : {}),
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
if (isEmailError(err)) {
|
|
321
|
+
const status = {
|
|
322
|
+
invalid_request: 400,
|
|
323
|
+
permission_denied: 403,
|
|
324
|
+
entitlement_exceeded: 403,
|
|
325
|
+
not_found: 404,
|
|
326
|
+
auth_expired: 409,
|
|
327
|
+
unsupported_capability: 422,
|
|
328
|
+
rate_limited: 429,
|
|
329
|
+
provider_unavailable: 503,
|
|
330
|
+
};
|
|
331
|
+
return {
|
|
332
|
+
status: status[err.code] ?? 500,
|
|
333
|
+
payload: {
|
|
334
|
+
error: { code: err.code, message: err.message, ...(err.data ? { data: err.data } : {}) },
|
|
335
|
+
},
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
return {
|
|
339
|
+
status: 500,
|
|
340
|
+
payload: { error: { code: 'internal', message: 'The request could not be completed.' } },
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
function jsonResponse(body, status, headers = {}) {
|
|
344
|
+
return new Response(JSON.stringify(body), {
|
|
345
|
+
status,
|
|
346
|
+
headers: {
|
|
347
|
+
'content-type': 'application/json; charset=UTF-8',
|
|
348
|
+
'cache-control': 'no-store',
|
|
349
|
+
'x-content-type-options': 'nosniff',
|
|
350
|
+
'referrer-policy': 'no-referrer',
|
|
351
|
+
...headers,
|
|
352
|
+
},
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
function canonicalJson(value) {
|
|
356
|
+
if (Array.isArray(value))
|
|
357
|
+
return `[${value.map(canonicalJson).join(',')}]`;
|
|
358
|
+
if (value && typeof value === 'object') {
|
|
359
|
+
return `{${Object.entries(value)
|
|
360
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
361
|
+
.map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`)
|
|
362
|
+
.join(',')}}`;
|
|
363
|
+
}
|
|
364
|
+
return JSON.stringify(value);
|
|
365
|
+
}
|
|
366
|
+
function requestHash(operation, request) {
|
|
367
|
+
return createHash('sha256').update(canonicalJson({ operation, request })).digest('hex');
|
|
368
|
+
}
|
|
369
|
+
function encodeContentDispositionFilename(filename) {
|
|
370
|
+
return encodeURIComponent(filename).replace(/['()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
371
|
+
}
|
|
372
|
+
function safeAttachmentFilename(filename) {
|
|
373
|
+
let safe = '';
|
|
374
|
+
for (const character of filename) {
|
|
375
|
+
const code = character.charCodeAt(0);
|
|
376
|
+
safe += code < 32 || code === 127 || '/\\:*?"<>|'.includes(character) ? '_' : character;
|
|
377
|
+
}
|
|
378
|
+
return safe || 'attachment';
|
|
379
|
+
}
|
|
380
|
+
function captureRestTelemetry(telemetry, properties) {
|
|
381
|
+
try {
|
|
382
|
+
telemetry?.capture('rest api called', { product_surface: 'rest', ...properties });
|
|
383
|
+
}
|
|
384
|
+
catch {
|
|
385
|
+
// Telemetry must not affect API responses.
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
async function runJson(c, deps, options, fn) {
|
|
389
|
+
const finishActivity = deps.telemetry?.beginActivity?.();
|
|
390
|
+
const startedAt = performance.now();
|
|
391
|
+
let outcome = 'error';
|
|
392
|
+
let errorCode;
|
|
393
|
+
let idempotencyStatus;
|
|
394
|
+
let reservation;
|
|
395
|
+
try {
|
|
396
|
+
const auth = c.get('restAuth');
|
|
397
|
+
const missing = options.capabilities.filter((capability) => !hasCapability(auth.permissions, capability));
|
|
398
|
+
if (missing.length) {
|
|
399
|
+
throw new EmailError('permission_denied', `This API key does not allow: ${missing.join(', ')}.`);
|
|
400
|
+
}
|
|
401
|
+
const service = c.get('restService');
|
|
402
|
+
if (options.idempotent) {
|
|
403
|
+
const idempotencyKey = c.req.header('idempotency-key');
|
|
404
|
+
if (!idempotencyKey || !/^[\x21-\x7e]{1,255}$/.test(idempotencyKey)) {
|
|
405
|
+
throw new RestFailure('invalid_request', 'Idempotency-Key is required and must contain 1 to 255 printable ASCII characters without spaces.', 400);
|
|
406
|
+
}
|
|
407
|
+
if (options.accountId !== undefined)
|
|
408
|
+
service.assertAccountAccess(options.accountId);
|
|
409
|
+
const hash = requestHash(options.operation, options.request);
|
|
410
|
+
const principalId = auth.keyId;
|
|
411
|
+
const result = reserveIdempotencyKey(deps.db, {
|
|
412
|
+
principalId,
|
|
413
|
+
idempotencyKey,
|
|
414
|
+
requestHash: hash,
|
|
415
|
+
});
|
|
416
|
+
idempotencyStatus = result.status;
|
|
417
|
+
if (result.status === 'conflict') {
|
|
418
|
+
throw new RestFailure('idempotency_conflict', 'This Idempotency-Key was already used for a different request.', 409);
|
|
419
|
+
}
|
|
420
|
+
if (result.status === 'in_progress') {
|
|
421
|
+
throw new RestFailure('idempotency_in_progress', 'A request with this Idempotency-Key is still in progress or ended with an uncertain outcome.', 409, undefined, '1');
|
|
422
|
+
}
|
|
423
|
+
if (result.status === 'replay') {
|
|
424
|
+
outcome = result.responseStatus < 400 ? 'success' : 'error';
|
|
425
|
+
try {
|
|
426
|
+
const parsed = JSON.parse(result.responseBody);
|
|
427
|
+
errorCode = parsed.error?.code;
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
errorCode = result.responseStatus >= 400 ? 'internal' : undefined;
|
|
431
|
+
}
|
|
432
|
+
return new Response(result.responseBody, {
|
|
433
|
+
status: result.responseStatus,
|
|
434
|
+
headers: {
|
|
435
|
+
'content-type': 'application/json; charset=UTF-8',
|
|
436
|
+
'cache-control': 'no-store',
|
|
437
|
+
'idempotency-replayed': 'true',
|
|
438
|
+
},
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
reservation = { principalId, idempotencyKey, requestHash: hash };
|
|
442
|
+
}
|
|
443
|
+
try {
|
|
444
|
+
const warning = options.quota === false ? undefined : service.enforceQuota();
|
|
445
|
+
const result = await fn();
|
|
446
|
+
const payload = {
|
|
447
|
+
data: result.data,
|
|
448
|
+
...(result.meta ? { meta: result.meta } : {}),
|
|
449
|
+
...(warning ? { warnings: [warning] } : {}),
|
|
450
|
+
};
|
|
451
|
+
const status = options.successStatus ?? 200;
|
|
452
|
+
const body = JSON.stringify(payload);
|
|
453
|
+
if (reservation) {
|
|
454
|
+
completeIdempotencyKey(deps.db, { ...reservation, responseStatus: status, responseBody: body });
|
|
455
|
+
}
|
|
456
|
+
outcome = 'success';
|
|
457
|
+
return new Response(body, {
|
|
458
|
+
status,
|
|
459
|
+
headers: { 'content-type': 'application/json; charset=UTF-8', 'cache-control': 'no-store' },
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
catch (err) {
|
|
463
|
+
const failure = apiFailure(err);
|
|
464
|
+
const body = JSON.stringify(failure.payload);
|
|
465
|
+
if (reservation) {
|
|
466
|
+
completeIdempotencyKey(deps.db, {
|
|
467
|
+
...reservation,
|
|
468
|
+
responseStatus: failure.status,
|
|
469
|
+
responseBody: body,
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
errorCode = failure.payload.error.code;
|
|
473
|
+
return new Response(body, {
|
|
474
|
+
status: failure.status,
|
|
475
|
+
headers: {
|
|
476
|
+
'content-type': 'application/json; charset=UTF-8',
|
|
477
|
+
'cache-control': 'no-store',
|
|
478
|
+
...(failure.retryAfter ? { 'retry-after': failure.retryAfter } : {}),
|
|
479
|
+
},
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
catch (err) {
|
|
484
|
+
const failure = apiFailure(err);
|
|
485
|
+
errorCode = failure.payload.error.code;
|
|
486
|
+
return jsonResponse(failure.payload, failure.status, failure.retryAfter ? { 'retry-after': failure.retryAfter } : {});
|
|
487
|
+
}
|
|
488
|
+
finally {
|
|
489
|
+
captureRestTelemetry(deps.telemetry, {
|
|
490
|
+
operation: options.operation,
|
|
491
|
+
outcome,
|
|
492
|
+
duration_ms: Math.round(performance.now() - startedAt),
|
|
493
|
+
...(errorCode ? { error_code: errorCode } : {}),
|
|
494
|
+
...(idempotencyStatus ? { idempotency_status: idempotencyStatus } : {}),
|
|
495
|
+
...options.telemetry,
|
|
496
|
+
});
|
|
497
|
+
finishActivity?.();
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
async function runAttachment(c, deps, accountId, messageId, attachmentId) {
|
|
501
|
+
const finishActivity = deps.telemetry?.beginActivity?.();
|
|
502
|
+
const startedAt = performance.now();
|
|
503
|
+
let outcome = 'error';
|
|
504
|
+
let errorCode;
|
|
505
|
+
try {
|
|
506
|
+
const auth = c.get('restAuth');
|
|
507
|
+
if (!hasCapability(auth.permissions, 'mail.read')) {
|
|
508
|
+
throw new EmailError('permission_denied', 'This API key does not allow: mail.read.');
|
|
509
|
+
}
|
|
510
|
+
const service = c.get('restService');
|
|
511
|
+
const warning = service.enforceQuota();
|
|
512
|
+
const { meta, content } = await service.getAttachment(accountId, messageId, attachmentId, deps.config.maxAttachmentBytes);
|
|
513
|
+
if (content.length > deps.config.maxAttachmentBytes) {
|
|
514
|
+
throw new EmailError('invalid_request', 'Attachment is too large to return through the REST API.', {
|
|
515
|
+
sizeBytes: content.length,
|
|
516
|
+
maxBytes: deps.config.maxAttachmentBytes,
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
const safeName = safeAttachmentFilename(meta.filename);
|
|
520
|
+
const asciiName = safeName.replace(/[^\x20-\x7e]/g, '_');
|
|
521
|
+
const mimeType = /^[\w!#$&^_.+-]+\/[\w!#$&^_.+-]+$/.test(meta.mimeType)
|
|
522
|
+
? meta.mimeType
|
|
523
|
+
: 'application/octet-stream';
|
|
524
|
+
outcome = 'success';
|
|
525
|
+
return new Response(new Uint8Array(content), {
|
|
526
|
+
status: 200,
|
|
527
|
+
headers: {
|
|
528
|
+
'content-type': mimeType,
|
|
529
|
+
'content-length': String(content.length),
|
|
530
|
+
'content-disposition': `attachment; filename="${asciiName}"; filename*=UTF-8''${encodeContentDispositionFilename(safeName)}`,
|
|
531
|
+
'cache-control': 'no-store',
|
|
532
|
+
...(warning ? { 'fluxmail-warning': warning.replace(/[\r\n]/g, ' ') } : {}),
|
|
533
|
+
},
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
catch (err) {
|
|
537
|
+
const failure = apiFailure(err);
|
|
538
|
+
errorCode = failure.payload.error.code;
|
|
539
|
+
return jsonResponse(failure.payload, failure.status);
|
|
540
|
+
}
|
|
541
|
+
finally {
|
|
542
|
+
captureRestTelemetry(deps.telemetry, {
|
|
543
|
+
operation: 'downloadAttachment',
|
|
544
|
+
outcome,
|
|
545
|
+
duration_ms: Math.round(performance.now() - startedAt),
|
|
546
|
+
...(errorCode ? { error_code: errorCode } : {}),
|
|
547
|
+
});
|
|
548
|
+
finishActivity?.();
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
function toSendInput(input) {
|
|
552
|
+
return {
|
|
553
|
+
body: input.body,
|
|
554
|
+
...(input.to ? { to: input.to } : {}),
|
|
555
|
+
...(input.cc ? { cc: input.cc } : {}),
|
|
556
|
+
...(input.bcc ? { bcc: input.bcc } : {}),
|
|
557
|
+
...(input.subject !== undefined ? { subject: input.subject } : {}),
|
|
558
|
+
...(input.replyToMessageId ? { replyToMessageId: input.replyToMessageId } : {}),
|
|
559
|
+
...(input.replyAll !== undefined ? { replyAll: input.replyAll } : {}),
|
|
560
|
+
...(input.attachments ? { attachments: input.attachments } : {}),
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
function toEmailQuery(input) {
|
|
564
|
+
const query = {};
|
|
565
|
+
for (const key of ['folder', 'text', 'from', 'to', 'subject', 'after', 'before', 'rawProviderQuery']) {
|
|
566
|
+
if (input[key] !== undefined)
|
|
567
|
+
query[key] = input[key];
|
|
568
|
+
}
|
|
569
|
+
for (const key of ['unreadOnly', 'starredOnly', 'hasAttachment']) {
|
|
570
|
+
if (input[key] !== undefined)
|
|
571
|
+
query[key] = input[key] === 'true';
|
|
572
|
+
}
|
|
573
|
+
return {
|
|
574
|
+
query,
|
|
575
|
+
page: {
|
|
576
|
+
...(input.pageSize ? { pageSize: Number(input.pageSize) } : {}),
|
|
577
|
+
...(input.pageToken ? { pageToken: input.pageToken } : {}),
|
|
578
|
+
},
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
const FIXED_ADMIN_AUDIT_PATHS = new Set([
|
|
582
|
+
'/api/v1/admin/connections',
|
|
583
|
+
'/api/v1/admin/imap/tests',
|
|
584
|
+
'/api/v1/admin/api-keys',
|
|
585
|
+
'/api/v1/admin/license/activate',
|
|
586
|
+
]);
|
|
587
|
+
function adminAuditPath(path) {
|
|
588
|
+
if (FIXED_ADMIN_AUDIT_PATHS.has(path))
|
|
589
|
+
return path;
|
|
590
|
+
if (/^\/api\/v1\/admin\/api-keys\/[^/]+$/.test(path))
|
|
591
|
+
return '/api/v1/admin/api-keys/:id';
|
|
592
|
+
if (/^\/api\/v1\/admin\/accounts\/[^/]+\/imap\/folders$/.test(path)) {
|
|
593
|
+
return '/api/v1/admin/accounts/:id/imap/folders';
|
|
594
|
+
}
|
|
595
|
+
return '/api/v1/admin/*';
|
|
596
|
+
}
|
|
597
|
+
function modifyAction(input) {
|
|
598
|
+
if (input.action === 'move') {
|
|
599
|
+
const folder = input.folder;
|
|
600
|
+
if (PROTECTED_MOVE_DESTINATIONS.has(folder.trim().toLowerCase())) {
|
|
601
|
+
throw new EmailError('invalid_request', 'Use the dedicated archive or trash action for this folder.');
|
|
602
|
+
}
|
|
603
|
+
return { move: folder };
|
|
604
|
+
}
|
|
605
|
+
if (input.action === 'addLabels' || input.action === 'removeLabels') {
|
|
606
|
+
const labels = input.labels;
|
|
607
|
+
if (labels.some((label) => SYSTEM_LABELS.has(label.trim().toLowerCase()))) {
|
|
608
|
+
throw new EmailError('invalid_request', 'System labels must be changed with their dedicated message action.');
|
|
609
|
+
}
|
|
610
|
+
return input.action === 'addLabels' ? { addLabels: labels } : { removeLabels: labels };
|
|
611
|
+
}
|
|
612
|
+
return input.action;
|
|
613
|
+
}
|
|
614
|
+
export function createRestApi(deps) {
|
|
615
|
+
const app = new OpenAPIHono({
|
|
616
|
+
defaultHook(result, _c) {
|
|
617
|
+
if (result.success)
|
|
618
|
+
return;
|
|
619
|
+
return jsonResponse({
|
|
620
|
+
error: {
|
|
621
|
+
code: 'invalid_request',
|
|
622
|
+
message: 'Request validation failed.',
|
|
623
|
+
data: {
|
|
624
|
+
issues: result.error.issues.map((issue) => ({ path: issue.path.join('.'), message: issue.message })),
|
|
625
|
+
},
|
|
626
|
+
},
|
|
627
|
+
}, 400);
|
|
628
|
+
},
|
|
629
|
+
});
|
|
630
|
+
app.use('/api/v1/admin/*', async (c, next) => {
|
|
631
|
+
await next();
|
|
632
|
+
if (!['POST', 'PATCH', 'PUT', 'DELETE'].includes(c.req.method))
|
|
633
|
+
return;
|
|
634
|
+
const auth = c.get('restAuth');
|
|
635
|
+
if (!auth)
|
|
636
|
+
return;
|
|
637
|
+
const resource = c.req.path.match(/\/(api-keys|accounts)\/((?:key|acct)_[^/]+)/);
|
|
638
|
+
const operation = `${c.req.method.toLowerCase()} ${adminAuditPath(c.req.path)}`;
|
|
639
|
+
let errorCode;
|
|
640
|
+
if (c.res.status >= 400) {
|
|
641
|
+
try {
|
|
642
|
+
const payload = (await c.res.clone().json());
|
|
643
|
+
if (typeof payload.error?.code === 'string')
|
|
644
|
+
errorCode = payload.error.code;
|
|
645
|
+
}
|
|
646
|
+
catch {
|
|
647
|
+
errorCode = 'internal';
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
try {
|
|
651
|
+
recordAdminAuditEvent(deps.db, {
|
|
652
|
+
operation,
|
|
653
|
+
outcome: c.res.status < 400 ? 'success' : 'error',
|
|
654
|
+
actorKeyId: auth.keyId,
|
|
655
|
+
actorMemberId: auth.memberId,
|
|
656
|
+
...(resource
|
|
657
|
+
? { resourceType: resource[1] === 'api-keys' ? 'api_key' : 'account', resourceId: resource[2] }
|
|
658
|
+
: {}),
|
|
659
|
+
...(errorCode ? { errorCode } : {}),
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
catch {
|
|
663
|
+
// Auditing must not replace the API response.
|
|
664
|
+
}
|
|
665
|
+
});
|
|
666
|
+
app.use('/api/v1/*', async (c, next) => {
|
|
667
|
+
if (c.req.path === '/api/v1' || c.req.path === '/api/v1/openapi.json')
|
|
668
|
+
return next();
|
|
669
|
+
const administrative = c.req.path.startsWith('/api/v1/admin/');
|
|
670
|
+
if (administrative) {
|
|
671
|
+
c.header('cache-control', 'no-store');
|
|
672
|
+
c.header('x-content-type-options', 'nosniff');
|
|
673
|
+
c.header('referrer-policy', 'no-referrer');
|
|
674
|
+
if (!administrationUsesHttps(c.req.raw, {
|
|
675
|
+
remoteAddress: c.env?.incoming?.socket.remoteAddress,
|
|
676
|
+
encrypted: Boolean(c.env?.incoming?.socket?.encrypted),
|
|
677
|
+
})) {
|
|
678
|
+
return jsonResponse({ error: { code: 'https_required', message: 'Administrative routes require HTTPS outside loopback.' } }, 400);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
let auth;
|
|
682
|
+
if (deps.config.authMode === 'none' && !administrative) {
|
|
683
|
+
auth = {
|
|
684
|
+
keyId: 'auth:none',
|
|
685
|
+
memberId: null,
|
|
686
|
+
role: null,
|
|
687
|
+
permissions: FULL_PERMISSION_POLICY,
|
|
688
|
+
accountIds: null,
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
else {
|
|
692
|
+
const authorization = c.req.header('authorization');
|
|
693
|
+
const bearer = authorization?.match(/^Bearer\s+([^\s]+)$/i)?.[1];
|
|
694
|
+
auth = bearer ? authenticateApiKey(deps.db, bearer) : null;
|
|
695
|
+
}
|
|
696
|
+
if (!auth) {
|
|
697
|
+
return jsonResponse({ error: { code: 'unauthorized', message: 'Pass an API key as a Bearer token.' } }, 401, {
|
|
698
|
+
'www-authenticate': 'Bearer',
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
c.set('restAuth', auth);
|
|
702
|
+
if (administrative && (c.req.method === 'POST' || c.req.method === 'PATCH' || c.req.method === 'PUT')) {
|
|
703
|
+
const mediaType = c.req.header('content-type')?.split(';', 1)[0]?.trim().toLowerCase();
|
|
704
|
+
if (mediaType !== 'application/json') {
|
|
705
|
+
return jsonResponse({ error: { code: 'unsupported_media_type', message: 'Content-Type must be application/json.' } }, 415);
|
|
706
|
+
}
|
|
707
|
+
if (await requestBodyExceedsLimit(c.req.raw)) {
|
|
708
|
+
return jsonResponse({ error: { code: 'request_too_large', message: 'Administrative request bodies are limited to 64 KiB.' } }, 413);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
const scope = deps.config.authMode === 'none'
|
|
712
|
+
? { memberId: null }
|
|
713
|
+
: { memberId: auth.memberId, role: auth.role, accountIds: auth.accountIds };
|
|
714
|
+
c.set('restService', deps.service.withScope(scope));
|
|
715
|
+
return next();
|
|
716
|
+
});
|
|
717
|
+
registerAdminRoutes(app, deps);
|
|
718
|
+
const discoveryRoute = createRoute({
|
|
719
|
+
method: 'get',
|
|
720
|
+
path: '/api/v1',
|
|
721
|
+
operationId: 'getApiInfo',
|
|
722
|
+
summary: 'Get API information',
|
|
723
|
+
description: 'Return the Fluxmail version and the URL of the OpenAPI document.',
|
|
724
|
+
responses: {
|
|
725
|
+
200: {
|
|
726
|
+
content: {
|
|
727
|
+
'application/json': {
|
|
728
|
+
schema: dataEnvelope(z.object({ name: z.literal('fluxmail'), version: z.string(), openapi: z.string() }).strict()),
|
|
729
|
+
},
|
|
730
|
+
},
|
|
731
|
+
description: 'REST API discovery',
|
|
732
|
+
},
|
|
733
|
+
},
|
|
734
|
+
});
|
|
735
|
+
app.openapi(discoveryRoute, (c) => c.json({ data: { name: 'fluxmail', version: VERSION, openapi: '/api/v1/openapi.json' } }));
|
|
736
|
+
const statusRoute = createRoute({
|
|
737
|
+
method: 'get',
|
|
738
|
+
path: '/api/v1/status',
|
|
739
|
+
operationId: 'getStatus',
|
|
740
|
+
summary: 'Get server status',
|
|
741
|
+
description: 'Return provider and mailbox status for the accounts available to the API key.',
|
|
742
|
+
...protectedRoute,
|
|
743
|
+
responses: {
|
|
744
|
+
200: { content: { 'application/json': { schema: dataEnvelope(z.record(z.unknown())) } }, description: 'Status' },
|
|
745
|
+
...errorResponses,
|
|
746
|
+
},
|
|
747
|
+
});
|
|
748
|
+
app.openapi(statusRoute, (c) => runJson(c, deps, { operation: 'getStatus', capabilities: ['mail.read'], quota: false }, async () => ({
|
|
749
|
+
data: await c.get('restService').status(),
|
|
750
|
+
})));
|
|
751
|
+
const accountsRoute = createRoute({
|
|
752
|
+
method: 'get',
|
|
753
|
+
path: '/api/v1/accounts',
|
|
754
|
+
operationId: 'listAccounts',
|
|
755
|
+
summary: 'List accounts',
|
|
756
|
+
description: 'List the email accounts available to the API key.',
|
|
757
|
+
...protectedRoute,
|
|
758
|
+
responses: {
|
|
759
|
+
200: {
|
|
760
|
+
content: { 'application/json': { schema: dataEnvelope(z.array(AccountSchema)) } },
|
|
761
|
+
description: 'Accounts',
|
|
762
|
+
},
|
|
763
|
+
...errorResponses,
|
|
764
|
+
},
|
|
765
|
+
});
|
|
766
|
+
app.openapi(accountsRoute, (c) => runJson(c, deps, { operation: 'listAccounts', capabilities: ['mail.read'] }, () => ({
|
|
767
|
+
data: c.get('restService').listAccounts(),
|
|
768
|
+
})));
|
|
769
|
+
const foldersRoute = createRoute({
|
|
770
|
+
method: 'get',
|
|
771
|
+
path: '/api/v1/accounts/{accountId}/folders',
|
|
772
|
+
operationId: 'listFolders',
|
|
773
|
+
summary: 'List folders',
|
|
774
|
+
description: 'List folders in an email account.',
|
|
775
|
+
request: { params: accountParams },
|
|
776
|
+
...protectedRoute,
|
|
777
|
+
responses: {
|
|
778
|
+
200: { content: { 'application/json': { schema: dataEnvelope(z.array(FolderSchema)) } }, description: 'Folders' },
|
|
779
|
+
...errorResponses,
|
|
780
|
+
},
|
|
781
|
+
});
|
|
782
|
+
app.openapi(foldersRoute, (c) => {
|
|
783
|
+
const { accountId } = c.req.valid('param');
|
|
784
|
+
return runJson(c, deps, { operation: 'listFolders', capabilities: ['mail.read'] }, async () => ({
|
|
785
|
+
data: await c.get('restService').listFolders(accountId),
|
|
786
|
+
}));
|
|
787
|
+
});
|
|
788
|
+
const listMessagesRoute = createRoute({
|
|
789
|
+
method: 'get',
|
|
790
|
+
path: '/api/v1/accounts/{accountId}/messages',
|
|
791
|
+
operationId: 'listMessages',
|
|
792
|
+
summary: 'List messages',
|
|
793
|
+
description: 'List and filter messages in an email account.',
|
|
794
|
+
request: { params: accountParams, query: messageQuerySchema },
|
|
795
|
+
...protectedRoute,
|
|
796
|
+
responses: {
|
|
797
|
+
200: { content: { 'application/json': { schema: pagedEnvelope(MessageSchema) } }, description: 'Messages' },
|
|
798
|
+
...errorResponses,
|
|
799
|
+
},
|
|
800
|
+
});
|
|
801
|
+
app.openapi(listMessagesRoute, (c) => {
|
|
802
|
+
const { accountId } = c.req.valid('param');
|
|
803
|
+
const { query, page } = toEmailQuery(c.req.valid('query'));
|
|
804
|
+
return runJson(c, deps, { operation: 'listMessages', capabilities: ['mail.read'] }, async () => {
|
|
805
|
+
const result = await c.get('restService').listMessages(accountId, query, page);
|
|
806
|
+
return { data: result.items, meta: result.nextPageToken ? { nextPageToken: result.nextPageToken } : {} };
|
|
807
|
+
});
|
|
808
|
+
});
|
|
809
|
+
const getMessageRoute = createRoute({
|
|
810
|
+
method: 'get',
|
|
811
|
+
path: '/api/v1/accounts/{accountId}/messages/{messageId}',
|
|
812
|
+
operationId: 'getMessage',
|
|
813
|
+
summary: 'Get a message',
|
|
814
|
+
description: 'Get one message by its provider ID.',
|
|
815
|
+
request: { params: messageParams },
|
|
816
|
+
...protectedRoute,
|
|
817
|
+
responses: {
|
|
818
|
+
200: { content: { 'application/json': { schema: dataEnvelope(MessageSchema) } }, description: 'Message' },
|
|
819
|
+
...errorResponses,
|
|
820
|
+
},
|
|
821
|
+
});
|
|
822
|
+
app.openapi(getMessageRoute, (c) => {
|
|
823
|
+
const { accountId, messageId } = c.req.valid('param');
|
|
824
|
+
return runJson(c, deps, { operation: 'getMessage', capabilities: ['mail.read'] }, async () => ({
|
|
825
|
+
data: await c.get('restService').getMessage(accountId, messageId),
|
|
826
|
+
}));
|
|
827
|
+
});
|
|
828
|
+
const getThreadRoute = createRoute({
|
|
829
|
+
method: 'get',
|
|
830
|
+
path: '/api/v1/accounts/{accountId}/threads/{threadId}',
|
|
831
|
+
operationId: 'getThread',
|
|
832
|
+
summary: 'Get a thread',
|
|
833
|
+
description: 'Get a complete email thread by its provider ID.',
|
|
834
|
+
request: { params: threadParams },
|
|
835
|
+
...protectedRoute,
|
|
836
|
+
responses: {
|
|
837
|
+
200: { content: { 'application/json': { schema: dataEnvelope(ThreadSchema) } }, description: 'Thread' },
|
|
838
|
+
...errorResponses,
|
|
839
|
+
},
|
|
840
|
+
});
|
|
841
|
+
app.openapi(getThreadRoute, (c) => {
|
|
842
|
+
const { accountId, threadId } = c.req.valid('param');
|
|
843
|
+
return runJson(c, deps, { operation: 'getThread', capabilities: ['mail.read'] }, async () => ({
|
|
844
|
+
data: await c.get('restService').getThread(accountId, threadId),
|
|
845
|
+
}));
|
|
846
|
+
});
|
|
847
|
+
const createDraftRoute = createRoute({
|
|
848
|
+
method: 'post',
|
|
849
|
+
path: '/api/v1/accounts/{accountId}/drafts',
|
|
850
|
+
operationId: 'createDraft',
|
|
851
|
+
summary: 'Create a draft',
|
|
852
|
+
description: 'Create a new draft or a reply draft in an email account.',
|
|
853
|
+
request: {
|
|
854
|
+
params: accountParams,
|
|
855
|
+
body: { required: true, content: { 'application/json': { schema: DraftRequestSchema } } },
|
|
856
|
+
},
|
|
857
|
+
...protectedRoute,
|
|
858
|
+
responses: {
|
|
859
|
+
201: { content: { 'application/json': { schema: dataEnvelope(MessageSchema) } }, description: 'Draft created' },
|
|
860
|
+
...errorResponses,
|
|
861
|
+
},
|
|
862
|
+
});
|
|
863
|
+
app.openapi(createDraftRoute, (c) => {
|
|
864
|
+
const { accountId } = c.req.valid('param');
|
|
865
|
+
const input = c.req.valid('json');
|
|
866
|
+
const capabilities = ['mail.drafts', ...(input.replyToMessageId ? ['mail.read'] : [])];
|
|
867
|
+
return runJson(c, deps, { operation: 'createDraft', capabilities, successStatus: 201 }, async () => ({
|
|
868
|
+
data: await c.get('restService').createDraft(accountId, toSendInput(input)),
|
|
869
|
+
}));
|
|
870
|
+
});
|
|
871
|
+
const updateDraftRoute = createRoute({
|
|
872
|
+
method: 'put',
|
|
873
|
+
path: '/api/v1/accounts/{accountId}/drafts/{draftId}',
|
|
874
|
+
operationId: 'updateDraft',
|
|
875
|
+
summary: 'Replace a draft',
|
|
876
|
+
description: 'Replace the full content of an existing draft.',
|
|
877
|
+
request: {
|
|
878
|
+
params: draftParams,
|
|
879
|
+
body: { required: true, content: { 'application/json': { schema: DraftRequestSchema } } },
|
|
880
|
+
},
|
|
881
|
+
...protectedRoute,
|
|
882
|
+
responses: {
|
|
883
|
+
200: { content: { 'application/json': { schema: dataEnvelope(MessageSchema) } }, description: 'Draft replaced' },
|
|
884
|
+
...errorResponses,
|
|
885
|
+
},
|
|
886
|
+
});
|
|
887
|
+
app.openapi(updateDraftRoute, (c) => {
|
|
888
|
+
const { accountId, draftId } = c.req.valid('param');
|
|
889
|
+
const input = c.req.valid('json');
|
|
890
|
+
const capabilities = ['mail.drafts', ...(input.replyToMessageId ? ['mail.read'] : [])];
|
|
891
|
+
return runJson(c, deps, { operation: 'updateDraft', capabilities }, async () => ({
|
|
892
|
+
data: await c.get('restService').updateDraft(accountId, draftId, toSendInput(input)),
|
|
893
|
+
}));
|
|
894
|
+
});
|
|
895
|
+
const deleteDraftRoute = createRoute({
|
|
896
|
+
method: 'delete',
|
|
897
|
+
path: '/api/v1/accounts/{accountId}/drafts/{draftId}',
|
|
898
|
+
operationId: 'deleteDraft',
|
|
899
|
+
summary: 'Delete a draft',
|
|
900
|
+
description: 'Delete an existing draft from an email account.',
|
|
901
|
+
request: { params: draftParams },
|
|
902
|
+
...protectedRoute,
|
|
903
|
+
responses: {
|
|
904
|
+
200: {
|
|
905
|
+
content: { 'application/json': { schema: dataEnvelope(z.object({ deleted: z.string() }).strict()) } },
|
|
906
|
+
description: 'Draft deleted',
|
|
907
|
+
},
|
|
908
|
+
...errorResponses,
|
|
909
|
+
},
|
|
910
|
+
});
|
|
911
|
+
app.openapi(deleteDraftRoute, (c) => {
|
|
912
|
+
const { accountId, draftId } = c.req.valid('param');
|
|
913
|
+
return runJson(c, deps, { operation: 'deleteDraft', capabilities: ['mail.drafts'] }, async () => {
|
|
914
|
+
await c.get('restService').deleteDraft(accountId, draftId);
|
|
915
|
+
return { data: { deleted: draftId } };
|
|
916
|
+
});
|
|
917
|
+
});
|
|
918
|
+
const sendRoute = createRoute({
|
|
919
|
+
method: 'post',
|
|
920
|
+
path: '/api/v1/accounts/{accountId}/send',
|
|
921
|
+
operationId: 'sendMessage',
|
|
922
|
+
summary: 'Send or schedule a message',
|
|
923
|
+
description: 'Send a message now or schedule it for a specified time.',
|
|
924
|
+
request: {
|
|
925
|
+
params: accountParams,
|
|
926
|
+
headers: idempotencyHeaders,
|
|
927
|
+
body: { required: true, content: { 'application/json': { schema: SendRequestSchema } } },
|
|
928
|
+
},
|
|
929
|
+
...protectedRoute,
|
|
930
|
+
responses: {
|
|
931
|
+
200: { content: { 'application/json': { schema: dataEnvelope(SendResultSchema) } }, description: 'Message sent' },
|
|
932
|
+
202: {
|
|
933
|
+
content: { 'application/json': { schema: dataEnvelope(ScheduledSendSchema) } },
|
|
934
|
+
description: 'Message scheduled',
|
|
935
|
+
},
|
|
936
|
+
...errorResponses,
|
|
937
|
+
},
|
|
938
|
+
});
|
|
939
|
+
app.openapi(sendRoute, (c) => {
|
|
940
|
+
const { accountId } = c.req.valid('param');
|
|
941
|
+
const input = c.req.valid('json');
|
|
942
|
+
const capabilities = [
|
|
943
|
+
'mail.send',
|
|
944
|
+
...('replyToMessageId' in input && input.replyToMessageId ? ['mail.read'] : []),
|
|
945
|
+
];
|
|
946
|
+
const sendAt = input.sendAt;
|
|
947
|
+
const request = 'draftId' in input ? { draftId: input.draftId } : toSendInput(input);
|
|
948
|
+
return runJson(c, deps, {
|
|
949
|
+
operation: 'sendMessage',
|
|
950
|
+
capabilities,
|
|
951
|
+
successStatus: sendAt ? 202 : 200,
|
|
952
|
+
request: { accountId, input },
|
|
953
|
+
idempotent: true,
|
|
954
|
+
accountId,
|
|
955
|
+
telemetry: {
|
|
956
|
+
mode: 'draftId' in input ? 'draft' : input.replyToMessageId ? 'reply' : 'direct',
|
|
957
|
+
scheduled: sendAt !== undefined,
|
|
958
|
+
reply_all: 'replyAll' in input && input.replyAll === true,
|
|
959
|
+
},
|
|
960
|
+
}, async () => ({
|
|
961
|
+
data: sendAt
|
|
962
|
+
? await c.get('restService').scheduleSend(accountId, request, sendAt)
|
|
963
|
+
: await c.get('restService').send(accountId, request),
|
|
964
|
+
}));
|
|
965
|
+
});
|
|
966
|
+
const listScheduledRoute = createRoute({
|
|
967
|
+
method: 'get',
|
|
968
|
+
path: '/api/v1/accounts/{accountId}/scheduled-sends',
|
|
969
|
+
operationId: 'listScheduledSends',
|
|
970
|
+
summary: 'List scheduled sends',
|
|
971
|
+
description: 'List scheduled messages in an email account.',
|
|
972
|
+
request: { params: accountParams },
|
|
973
|
+
...protectedRoute,
|
|
974
|
+
responses: {
|
|
975
|
+
200: {
|
|
976
|
+
content: { 'application/json': { schema: dataEnvelope(z.array(ScheduledSendSchema)) } },
|
|
977
|
+
description: 'Scheduled sends',
|
|
978
|
+
},
|
|
979
|
+
...errorResponses,
|
|
980
|
+
},
|
|
981
|
+
});
|
|
982
|
+
app.openapi(listScheduledRoute, (c) => {
|
|
983
|
+
const { accountId } = c.req.valid('param');
|
|
984
|
+
return runJson(c, deps, { operation: 'listScheduledSends', capabilities: ['mail.read'] }, () => ({
|
|
985
|
+
data: c.get('restService').listScheduled(accountId),
|
|
986
|
+
}));
|
|
987
|
+
});
|
|
988
|
+
const cancelScheduledRoute = createRoute({
|
|
989
|
+
method: 'delete',
|
|
990
|
+
path: '/api/v1/accounts/{accountId}/scheduled-sends/{scheduleId}',
|
|
991
|
+
operationId: 'cancelScheduledSend',
|
|
992
|
+
summary: 'Cancel a scheduled send',
|
|
993
|
+
description: 'Cancel a pending scheduled send and keep its provider draft.',
|
|
994
|
+
request: { params: scheduleParams },
|
|
995
|
+
...protectedRoute,
|
|
996
|
+
responses: {
|
|
997
|
+
200: {
|
|
998
|
+
content: {
|
|
999
|
+
'application/json': {
|
|
1000
|
+
schema: dataEnvelope(z.object({ scheduleId: z.string(), draftId: z.string(), draftKept: z.literal(true) }).strict()),
|
|
1001
|
+
},
|
|
1002
|
+
},
|
|
1003
|
+
description: 'Scheduled send canceled',
|
|
1004
|
+
},
|
|
1005
|
+
...errorResponses,
|
|
1006
|
+
},
|
|
1007
|
+
});
|
|
1008
|
+
app.openapi(cancelScheduledRoute, (c) => {
|
|
1009
|
+
const { accountId, scheduleId } = c.req.valid('param');
|
|
1010
|
+
return runJson(c, deps, { operation: 'cancelScheduledSend', capabilities: ['mail.drafts'] }, () => {
|
|
1011
|
+
const exists = c
|
|
1012
|
+
.get('restService')
|
|
1013
|
+
.listScheduled(accountId)
|
|
1014
|
+
.some((schedule) => schedule.scheduleId === scheduleId);
|
|
1015
|
+
if (!exists)
|
|
1016
|
+
throw new EmailError('not_found', `No scheduled send with id ${scheduleId}`);
|
|
1017
|
+
return { data: c.get('restService').cancelScheduled(scheduleId) };
|
|
1018
|
+
});
|
|
1019
|
+
});
|
|
1020
|
+
const forwardRoute = createRoute({
|
|
1021
|
+
method: 'post',
|
|
1022
|
+
path: '/api/v1/accounts/{accountId}/messages/{messageId}/forward',
|
|
1023
|
+
operationId: 'forwardMessage',
|
|
1024
|
+
summary: 'Forward a message',
|
|
1025
|
+
description: 'Forward a message to one or more recipients.',
|
|
1026
|
+
request: {
|
|
1027
|
+
params: messageParams,
|
|
1028
|
+
headers: idempotencyHeaders,
|
|
1029
|
+
body: { required: true, content: { 'application/json': { schema: ForwardRequestSchema } } },
|
|
1030
|
+
},
|
|
1031
|
+
...protectedRoute,
|
|
1032
|
+
responses: {
|
|
1033
|
+
200: { content: { 'application/json': { schema: dataEnvelope(SendResultSchema) } }, description: 'Forward sent' },
|
|
1034
|
+
...errorResponses,
|
|
1035
|
+
},
|
|
1036
|
+
});
|
|
1037
|
+
app.openapi(forwardRoute, (c) => {
|
|
1038
|
+
const { accountId, messageId } = c.req.valid('param');
|
|
1039
|
+
const input = c.req.valid('json');
|
|
1040
|
+
return runJson(c, deps, {
|
|
1041
|
+
operation: 'forwardMessage',
|
|
1042
|
+
capabilities: ['mail.send', 'mail.read'],
|
|
1043
|
+
request: { accountId, messageId, input },
|
|
1044
|
+
idempotent: true,
|
|
1045
|
+
accountId,
|
|
1046
|
+
telemetry: { include_attachments: input.includeAttachments !== false },
|
|
1047
|
+
}, async () => ({
|
|
1048
|
+
data: await c.get('restService').forward(accountId, { messageId, ...input }),
|
|
1049
|
+
}));
|
|
1050
|
+
});
|
|
1051
|
+
const modifyRoute = createRoute({
|
|
1052
|
+
method: 'post',
|
|
1053
|
+
path: '/api/v1/accounts/{accountId}/messages/actions',
|
|
1054
|
+
operationId: 'modifyMessages',
|
|
1055
|
+
summary: 'Modify messages',
|
|
1056
|
+
description: 'Apply one mailbox action to a batch of messages.',
|
|
1057
|
+
request: {
|
|
1058
|
+
params: accountParams,
|
|
1059
|
+
body: { required: true, content: { 'application/json': { schema: ModifyRequestSchema } } },
|
|
1060
|
+
},
|
|
1061
|
+
...protectedRoute,
|
|
1062
|
+
responses: {
|
|
1063
|
+
200: {
|
|
1064
|
+
content: {
|
|
1065
|
+
'application/json': {
|
|
1066
|
+
schema: dataEnvelope(z.object({ modified: z.number().int(), action: z.enum(modifyActionNames) }).strict()),
|
|
1067
|
+
},
|
|
1068
|
+
},
|
|
1069
|
+
description: 'Messages modified',
|
|
1070
|
+
},
|
|
1071
|
+
...errorResponses,
|
|
1072
|
+
},
|
|
1073
|
+
});
|
|
1074
|
+
app.openapi(modifyRoute, (c) => {
|
|
1075
|
+
const { accountId } = c.req.valid('param');
|
|
1076
|
+
const input = c.req.valid('json');
|
|
1077
|
+
const capability = MODIFY_CAPABILITIES[input.action];
|
|
1078
|
+
return runJson(c, deps, {
|
|
1079
|
+
operation: 'modifyMessages',
|
|
1080
|
+
capabilities: [capability],
|
|
1081
|
+
telemetry: { action: input.action },
|
|
1082
|
+
}, async () => {
|
|
1083
|
+
await c.get('restService').modify(accountId, input.messageIds, modifyAction(input));
|
|
1084
|
+
return { data: { modified: input.messageIds.length, action: input.action } };
|
|
1085
|
+
});
|
|
1086
|
+
});
|
|
1087
|
+
const attachmentRoute = createRoute({
|
|
1088
|
+
method: 'get',
|
|
1089
|
+
path: '/api/v1/accounts/{accountId}/messages/{messageId}/attachments/{attachmentId}',
|
|
1090
|
+
operationId: 'downloadAttachment',
|
|
1091
|
+
summary: 'Download an attachment',
|
|
1092
|
+
description: 'Download one attachment as raw bytes.',
|
|
1093
|
+
request: { params: attachmentParams },
|
|
1094
|
+
...protectedRoute,
|
|
1095
|
+
responses: {
|
|
1096
|
+
200: {
|
|
1097
|
+
content: { 'application/octet-stream': { schema: z.string().openapi({ format: 'binary' }) } },
|
|
1098
|
+
description: 'Attachment bytes',
|
|
1099
|
+
},
|
|
1100
|
+
...errorResponses,
|
|
1101
|
+
},
|
|
1102
|
+
});
|
|
1103
|
+
app.openapi(attachmentRoute, (c) => {
|
|
1104
|
+
const { accountId, messageId, attachmentId } = c.req.valid('param');
|
|
1105
|
+
return runAttachment(c, deps, accountId, messageId, attachmentId);
|
|
1106
|
+
});
|
|
1107
|
+
app.openAPIRegistry.registerComponent('securitySchemes', 'bearerAuth', {
|
|
1108
|
+
type: 'http',
|
|
1109
|
+
scheme: 'bearer',
|
|
1110
|
+
bearerFormat: 'Fluxmail API key',
|
|
1111
|
+
});
|
|
1112
|
+
app.doc31('/api/v1/openapi.json', {
|
|
1113
|
+
openapi: '3.1.0',
|
|
1114
|
+
info: {
|
|
1115
|
+
title: 'Fluxmail REST API',
|
|
1116
|
+
version: VERSION,
|
|
1117
|
+
description: 'Read, draft, send, schedule, and organize email through connected Fluxmail accounts.',
|
|
1118
|
+
},
|
|
1119
|
+
});
|
|
1120
|
+
app.all('/api/v1/*', () => jsonResponse({ error: { code: 'not_found', message: 'No REST route matches this request.' } }, 404));
|
|
1121
|
+
app.onError((err) => {
|
|
1122
|
+
const malformedJson = err instanceof SyntaxError || (err instanceof HTTPException && err.status === 400)
|
|
1123
|
+
? new EmailError('invalid_request', 'Malformed JSON body.')
|
|
1124
|
+
: err;
|
|
1125
|
+
const failure = apiFailure(malformedJson);
|
|
1126
|
+
return jsonResponse(failure.payload, failure.status);
|
|
1127
|
+
});
|
|
1128
|
+
return app;
|
|
1129
|
+
}
|
|
1130
|
+
//# sourceMappingURL=rest.js.map
|