fluxmail 0.10.0 → 0.11.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 +1 -1
- package/dist/accounts/googleAuth.d.ts.map +1 -1
- package/dist/accounts/googleAuth.js +2 -1
- package/dist/accounts/googleAuth.js.map +1 -1
- package/dist/accounts/registry.d.ts.map +1 -1
- package/dist/accounts/registry.js +12 -5
- package/dist/accounts/registry.js.map +1 -1
- package/dist/accounts/telemetry.d.ts +3 -0
- package/dist/accounts/telemetry.d.ts.map +1 -1
- package/dist/accounts/telemetry.js +18 -0
- package/dist/accounts/telemetry.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +83 -24
- package/dist/cli.js.map +1 -1
- package/dist/cliInstances.d.ts +5 -2
- package/dist/cliInstances.d.ts.map +1 -1
- package/dist/cliInstances.js +74 -22
- package/dist/cliInstances.js.map +1 -1
- package/dist/cliMail.d.ts +2 -0
- package/dist/cliMail.d.ts.map +1 -1
- package/dist/cliMail.js +106 -18
- package/dist/cliMail.js.map +1 -1
- package/dist/cliOutput.d.ts +9 -0
- package/dist/cliOutput.d.ts.map +1 -0
- package/dist/cliOutput.js +76 -0
- package/dist/cliOutput.js.map +1 -0
- package/dist/context.js +1 -1
- package/dist/context.js.map +1 -1
- package/dist/http/admin.d.ts.map +1 -1
- package/dist/http/admin.js +2 -0
- package/dist/http/admin.js.map +1 -1
- package/dist/http/app.d.ts.map +1 -1
- package/dist/http/app.js +4 -0
- package/dist/http/app.js.map +1 -1
- package/dist/http/identity.d.ts.map +1 -1
- package/dist/http/identity.js +2 -0
- package/dist/http/identity.js.map +1 -1
- package/dist/http/rest.d.ts.map +1 -1
- package/dist/http/rest.js +250 -48
- package/dist/http/rest.js.map +1 -1
- package/dist/licensing/entitlements.d.ts +23 -5
- package/dist/licensing/entitlements.d.ts.map +1 -1
- package/dist/licensing/entitlements.js +101 -22
- package/dist/licensing/entitlements.js.map +1 -1
- package/dist/licensing/lease.d.ts +1 -1
- package/dist/licensing/lease.d.ts.map +1 -1
- package/dist/licensing/refresher.d.ts.map +1 -1
- package/dist/licensing/refresher.js +5 -2
- package/dist/licensing/refresher.js.map +1 -1
- package/dist/mcp/buildServer.d.ts.map +1 -1
- package/dist/mcp/buildServer.js +230 -78
- package/dist/mcp/buildServer.js.map +1 -1
- package/dist/mcp/outputSchemas.d.ts +2923 -0
- package/dist/mcp/outputSchemas.d.ts.map +1 -0
- package/dist/mcp/outputSchemas.js +108 -0
- package/dist/mcp/outputSchemas.js.map +1 -0
- package/dist/scheduler/sendScheduler.d.ts +2 -0
- package/dist/scheduler/sendScheduler.d.ts.map +1 -1
- package/dist/scheduler/sendScheduler.js +13 -3
- package/dist/scheduler/sendScheduler.js.map +1 -1
- package/dist/service/deliveryCoordinator.d.ts +45 -0
- package/dist/service/deliveryCoordinator.d.ts.map +1 -0
- package/dist/service/deliveryCoordinator.js +296 -0
- package/dist/service/deliveryCoordinator.js.map +1 -0
- package/dist/service/emailService.d.ts +47 -5
- package/dist/service/emailService.d.ts.map +1 -1
- package/dist/service/emailService.js +245 -39
- package/dist/service/emailService.js.map +1 -1
- package/dist/service/publicErrors.d.ts +12 -0
- package/dist/service/publicErrors.d.ts.map +1 -0
- package/dist/service/publicErrors.js +41 -0
- package/dist/service/publicErrors.js.map +1 -0
- package/dist/service/searchCursor.d.ts.map +1 -1
- package/dist/service/searchCursor.js +2 -1
- package/dist/service/searchCursor.js.map +1 -1
- package/dist/storage/db.d.ts +252 -2
- package/dist/storage/db.d.ts.map +1 -1
- package/dist/storage/db.js +57 -1
- package/dist/storage/db.js.map +1 -1
- package/dist/storage/members.d.ts.map +1 -1
- package/dist/storage/members.js +3 -1
- package/dist/storage/members.js.map +1 -1
- package/dist/storage/restIdempotency.d.ts +6 -0
- package/dist/storage/restIdempotency.d.ts.map +1 -1
- package/dist/storage/restIdempotency.js +8 -0
- package/dist/storage/restIdempotency.js.map +1 -1
- package/dist/storage/scheduledSends.d.ts +2 -1
- package/dist/storage/scheduledSends.d.ts.map +1 -1
- package/dist/storage/scheduledSends.js +6 -0
- package/dist/storage/scheduledSends.js.map +1 -1
- package/package.json +5 -5
|
@@ -4,6 +4,7 @@ import { type Entitlements } from '../licensing/entitlements.js';
|
|
|
4
4
|
import type { FluxmailDb } from '../storage/db.js';
|
|
5
5
|
import type { Principal } from '../auth.js';
|
|
6
6
|
import { type ScheduledSendStatus } from '../storage/scheduledSends.js';
|
|
7
|
+
import { type DeliveryOperation } from './deliveryCoordinator.js';
|
|
7
8
|
import { type ConfiguredSendAsInput } from '../storage/sendAs.js';
|
|
8
9
|
export interface SendInput extends DraftInput {
|
|
9
10
|
/** With replyToMessageId: compute recipients from the original (reply-all semantics). */
|
|
@@ -41,6 +42,7 @@ export interface ServiceStatus {
|
|
|
41
42
|
}
|
|
42
43
|
export interface ScheduledSendInfo {
|
|
43
44
|
scheduleId: string;
|
|
45
|
+
operationId?: string;
|
|
44
46
|
accountId: string;
|
|
45
47
|
draftId: string;
|
|
46
48
|
/** ISO 8601 UTC. */
|
|
@@ -79,6 +81,30 @@ export interface BatchSearchResult {
|
|
|
79
81
|
groups: BatchSearchGroup[];
|
|
80
82
|
exhausted: boolean;
|
|
81
83
|
}
|
|
84
|
+
export interface ModifyResult {
|
|
85
|
+
action: ModifyAction;
|
|
86
|
+
succeededIds: string[];
|
|
87
|
+
failed: Array<{
|
|
88
|
+
messageId: string;
|
|
89
|
+
code: string;
|
|
90
|
+
}>;
|
|
91
|
+
uncertainIds: string[];
|
|
92
|
+
}
|
|
93
|
+
export interface SendPreview {
|
|
94
|
+
accountId: string;
|
|
95
|
+
from: string;
|
|
96
|
+
to: EmailAddress[];
|
|
97
|
+
cc: EmailAddress[];
|
|
98
|
+
bcc: EmailAddress[];
|
|
99
|
+
subject: string;
|
|
100
|
+
attachments: Array<{
|
|
101
|
+
filename: string;
|
|
102
|
+
mimeType: string;
|
|
103
|
+
sizeBytes: number;
|
|
104
|
+
}>;
|
|
105
|
+
bodyTextChars: number;
|
|
106
|
+
bodyHtmlChars: number;
|
|
107
|
+
}
|
|
82
108
|
/** Parse and validate a sendAt timestamp; returns epoch ms. */
|
|
83
109
|
export declare function resolveSendAt(sendAtIso: string, now?: number): number;
|
|
84
110
|
/**
|
|
@@ -90,11 +116,12 @@ export declare class EmailService {
|
|
|
90
116
|
private readonly registry;
|
|
91
117
|
private readonly db;
|
|
92
118
|
private readonly principal?;
|
|
119
|
+
private readonly maxAttachmentBytes;
|
|
93
120
|
/** Wired to SendScheduler.wake() in long-lived processes; a no-op for one-shot CLI commands. */
|
|
94
121
|
onScheduleChanged: () => void;
|
|
95
122
|
private readonly cursorSecret;
|
|
96
123
|
private readonly cursorCodec;
|
|
97
|
-
constructor(registry: AccountRegistry, db: FluxmailDb, principal?: Principal | undefined, cursorSecret?: Buffer);
|
|
124
|
+
constructor(registry: AccountRegistry, db: FluxmailDb, principal?: Principal | undefined, cursorSecret?: Buffer, maxAttachmentBytes?: number);
|
|
98
125
|
/**
|
|
99
126
|
* A view restricted to a member's mailbox grants and optional connection
|
|
100
127
|
* allowlist. Internal background workers use the default service instead.
|
|
@@ -118,9 +145,9 @@ export declare class EmailService {
|
|
|
118
145
|
listAccounts(): Account[];
|
|
119
146
|
status(): Promise<ServiceStatus>;
|
|
120
147
|
/**
|
|
121
|
-
* Gate for MCP tool calls: throws once
|
|
122
|
-
*
|
|
123
|
-
*
|
|
148
|
+
* Gate for MCP tool calls: throws once the instance is over the entitled
|
|
149
|
+
* caps, after a lapse or once a cap reduction's grace period ends; returns a
|
|
150
|
+
* warning to attach to results while a grace period runs or after a lapse.
|
|
124
151
|
*/
|
|
125
152
|
enforceQuota(): string | undefined;
|
|
126
153
|
private scheduledStatus;
|
|
@@ -135,6 +162,19 @@ export declare class EmailService {
|
|
|
135
162
|
createDraft(accountId: string | undefined, d: SendInput): Promise<Message>;
|
|
136
163
|
updateDraft(accountId: string | undefined, draftId: string, d: SendInput): Promise<Message>;
|
|
137
164
|
deleteDraft(accountId: string | undefined, draftId: string): Promise<void>;
|
|
165
|
+
getDraft(accountId: string | undefined, draftId: string): Promise<Message>;
|
|
166
|
+
previewSend(accountId: string | undefined, input: SendInput | {
|
|
167
|
+
draftId: string;
|
|
168
|
+
}): Promise<SendPreview>;
|
|
169
|
+
getDelivery(accountId: string, operationId: string): DeliveryOperation;
|
|
170
|
+
deliver(accountId: string | undefined, input: SendInput | {
|
|
171
|
+
draftId: string;
|
|
172
|
+
}, key: string): Promise<DeliveryOperation>;
|
|
173
|
+
deliverForward(accountId: string | undefined, input: ForwardInput, key: string): Promise<DeliveryOperation>;
|
|
174
|
+
deliverScheduled(accountId: string, draftId: string, scheduleId: string): Promise<DeliveryOperation>;
|
|
175
|
+
scheduleDelivery(accountId: string | undefined, input: SendInput | {
|
|
176
|
+
draftId: string;
|
|
177
|
+
}, sendAt: string, key: string): Promise<DeliveryOperation>;
|
|
138
178
|
send(accountId: string | undefined, input: SendInput | {
|
|
139
179
|
draftId: string;
|
|
140
180
|
}): Promise<SendResult>;
|
|
@@ -145,6 +185,7 @@ export declare class EmailService {
|
|
|
145
185
|
scheduleSend(accountId: string | undefined, input: SendInput | {
|
|
146
186
|
draftId: string;
|
|
147
187
|
}, sendAtIso: string): Promise<ScheduledSendInfo>;
|
|
188
|
+
private createSchedule;
|
|
148
189
|
listScheduled(accountId?: string): ScheduledSendInfo[];
|
|
149
190
|
/** Cancels a pending schedule; the provider draft is kept. */
|
|
150
191
|
cancelScheduled(scheduleId: string): {
|
|
@@ -162,11 +203,12 @@ export declare class EmailService {
|
|
|
162
203
|
private validateSender;
|
|
163
204
|
private selectReplySender;
|
|
164
205
|
forward(accountId: string | undefined, input: ForwardInput): Promise<SendResult>;
|
|
165
|
-
modify(accountId: string | undefined, ids: string[], action: ModifyAction): Promise<
|
|
206
|
+
modify(accountId: string | undefined, ids: string[], action: ModifyAction): Promise<ModifyResult>;
|
|
166
207
|
getAttachment(accountId: string | undefined, messageId: string, attachmentId: string, maxBytes?: number): Promise<{
|
|
167
208
|
meta: AttachmentMeta;
|
|
168
209
|
content: Buffer;
|
|
169
210
|
}>;
|
|
211
|
+
private assertMailContent;
|
|
170
212
|
}
|
|
171
213
|
export declare function buildForwardBody(original: Message, comment?: string): {
|
|
172
214
|
text?: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"emailService.d.ts","sourceRoot":"","sources":["../../src/service/emailService.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"emailService.d.ts","sourceRoot":"","sources":["../../src/service/emailService.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import { computeReplyRecipients, EmailError, formatAddressList, forwardSubject, isEmailError, isPortableFolderRole, normalizeEmailQuery, parseSingleAddress, supportsPortableEmailQuery, } from '@fluxmail/core';
|
|
1
|
+
import { computeReplyRecipients, EmailError, formatAddressList, forwardSubject, isEmailError, isPortableFolderRole, normalizeEmailQuery, parseSingleAddress, replySubject, supportsPortableEmailQuery, } from '@fluxmail/core';
|
|
2
2
|
import { randomBytes } from 'node:crypto';
|
|
3
3
|
import { assertWithinQuota, checkLicenseState } from '../licensing/entitlements.js';
|
|
4
4
|
import { listMembers } from '../storage/members.js';
|
|
5
5
|
import { canAccessAccount, canAdminister, canSeeAccountMetadata } from '../authorization.js';
|
|
6
6
|
import { cancelScheduledSend, countPending, createScheduledSend, findPendingByDraft, getScheduledSend, listScheduledSends, markSent, } from '../storage/scheduledSends.js';
|
|
7
7
|
import { SearchCursorCodec } from './searchCursor.js';
|
|
8
|
+
import { DeliveryCoordinator, isDefiniteDeliveryFailure } from './deliveryCoordinator.js';
|
|
9
|
+
import { ClientInputError } from './publicErrors.js';
|
|
10
|
+
import { DEFAULT_MAX_ATTACHMENT_BYTES } from '../config.js';
|
|
8
11
|
import { listConfiguredSendAs, replaceConfiguredSendAs } from '../storage/sendAs.js';
|
|
9
12
|
const SCHEDULE_GRACE_MS = 60_000;
|
|
10
13
|
const SCHEDULE_MAX_HORIZON_MS = 365 * 24 * 3_600_000;
|
|
@@ -64,13 +67,13 @@ export function resolveSendAt(sendAtIso, now = Date.now()) {
|
|
|
64
67
|
// Require an ISO 8601 shape: Date.parse alone is lenient enough to accept junk.
|
|
65
68
|
const sendAt = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(sendAtIso) ? Date.parse(sendAtIso) : NaN;
|
|
66
69
|
if (Number.isNaN(sendAt)) {
|
|
67
|
-
throw new
|
|
70
|
+
throw new ClientInputError('invalid_request', `Could not parse sendAt: "${sendAtIso}" (expected ISO 8601)`);
|
|
68
71
|
}
|
|
69
72
|
if (sendAt < now - SCHEDULE_GRACE_MS) {
|
|
70
|
-
throw new
|
|
73
|
+
throw new ClientInputError('invalid_request', 'sendAt is in the past; leave it out to send now');
|
|
71
74
|
}
|
|
72
75
|
if (sendAt > now + SCHEDULE_MAX_HORIZON_MS) {
|
|
73
|
-
throw new
|
|
76
|
+
throw new ClientInputError('invalid_request', 'sendAt is more than a year away');
|
|
74
77
|
}
|
|
75
78
|
return sendAt;
|
|
76
79
|
}
|
|
@@ -98,14 +101,16 @@ export class EmailService {
|
|
|
98
101
|
registry;
|
|
99
102
|
db;
|
|
100
103
|
principal;
|
|
104
|
+
maxAttachmentBytes;
|
|
101
105
|
/** Wired to SendScheduler.wake() in long-lived processes; a no-op for one-shot CLI commands. */
|
|
102
106
|
onScheduleChanged = () => { };
|
|
103
107
|
cursorSecret;
|
|
104
108
|
cursorCodec;
|
|
105
|
-
constructor(registry, db, principal, cursorSecret) {
|
|
109
|
+
constructor(registry, db, principal, cursorSecret, maxAttachmentBytes = DEFAULT_MAX_ATTACHMENT_BYTES) {
|
|
106
110
|
this.registry = registry;
|
|
107
111
|
this.db = db;
|
|
108
112
|
this.principal = principal;
|
|
113
|
+
this.maxAttachmentBytes = maxAttachmentBytes;
|
|
109
114
|
this.cursorSecret = cursorSecret ?? randomBytes(32);
|
|
110
115
|
this.cursorCodec = new SearchCursorCodec(this.cursorSecret);
|
|
111
116
|
}
|
|
@@ -114,7 +119,7 @@ export class EmailService {
|
|
|
114
119
|
* allowlist. Internal background workers use the default service instead.
|
|
115
120
|
*/
|
|
116
121
|
withPrincipal(principal) {
|
|
117
|
-
const scoped = new EmailService(this.registry, this.db, principal, this.cursorSecret);
|
|
122
|
+
const scoped = new EmailService(this.registry, this.db, principal, this.cursorSecret, this.maxAttachmentBytes);
|
|
118
123
|
scoped.onScheduleChanged = () => this.onScheduleChanged();
|
|
119
124
|
return scoped;
|
|
120
125
|
}
|
|
@@ -151,10 +156,10 @@ export class EmailService {
|
|
|
151
156
|
return this.registry.resolveAccountId();
|
|
152
157
|
const accessible = this.accessibleAccounts();
|
|
153
158
|
if (accessible.length === 0) {
|
|
154
|
-
throw new
|
|
159
|
+
throw new ClientInputError('invalid_request', 'No email accounts are available for this member.');
|
|
155
160
|
}
|
|
156
161
|
if (accessible.length > 1) {
|
|
157
|
-
throw new
|
|
162
|
+
throw new ClientInputError('invalid_request', `Multiple accounts are available; specify accountId. Available: ${accessible
|
|
158
163
|
.map((a) => `${a.id} (${a.email})`)
|
|
159
164
|
.join(', ')}`);
|
|
160
165
|
}
|
|
@@ -169,7 +174,7 @@ export class EmailService {
|
|
|
169
174
|
const resolvedId = this.resolveScopedAccountId(accountId);
|
|
170
175
|
const account = this.registry.getAccount(resolvedId);
|
|
171
176
|
if (account.status === 'disabled') {
|
|
172
|
-
throw new
|
|
177
|
+
throw new ClientInputError('invalid_request', `Account ${resolvedId} is disabled`);
|
|
173
178
|
}
|
|
174
179
|
try {
|
|
175
180
|
const result = await fn(this.registry.getProvider(resolvedId), resolvedId, account);
|
|
@@ -240,15 +245,17 @@ export class EmailService {
|
|
|
240
245
|
};
|
|
241
246
|
}
|
|
242
247
|
/**
|
|
243
|
-
* Gate for MCP tool calls: throws once
|
|
244
|
-
*
|
|
245
|
-
*
|
|
248
|
+
* Gate for MCP tool calls: throws once the instance is over the entitled
|
|
249
|
+
* caps, after a lapse or once a cap reduction's grace period ends; returns a
|
|
250
|
+
* warning to attach to results while a grace period runs or after a lapse.
|
|
246
251
|
*/
|
|
247
252
|
enforceQuota() {
|
|
248
253
|
if (!this.isInternal() && (!this.principal || !canAdminister(this.principal, 'admin.license'))) {
|
|
249
254
|
const state = checkLicenseState(this.db);
|
|
250
|
-
if (state.
|
|
251
|
-
throw new EmailError('entitlement_exceeded', 'This Fluxmail instance is over its plan limits. Ask an administrator to
|
|
255
|
+
if (state.blocked) {
|
|
256
|
+
throw new EmailError('entitlement_exceeded', 'This Fluxmail instance is over its plan limits. Ask an administrator to ' +
|
|
257
|
+
(state.entitlements.licensed ? 'upgrade the plan' : 'renew the license') +
|
|
258
|
+
' or reduce usage.');
|
|
252
259
|
}
|
|
253
260
|
// License dates and renewal state are instance administration details.
|
|
254
261
|
return undefined;
|
|
@@ -296,14 +303,14 @@ export class EmailService {
|
|
|
296
303
|
const normalized = identities.map((identity) => {
|
|
297
304
|
const parsed = parseSingleAddress(identity.email.trim());
|
|
298
305
|
if (!parsed)
|
|
299
|
-
throw new
|
|
306
|
+
throw new ClientInputError('invalid_request', `Could not parse sender address: "${identity.email}"`);
|
|
300
307
|
const email = parsed.email;
|
|
301
308
|
const key = email.toLowerCase();
|
|
302
309
|
if (key === account.email.toLowerCase()) {
|
|
303
|
-
throw new
|
|
310
|
+
throw new ClientInputError('invalid_request', 'Do not include the connected account address in configured aliases.');
|
|
304
311
|
}
|
|
305
312
|
if (seen.has(key))
|
|
306
|
-
throw new
|
|
313
|
+
throw new ClientInputError('invalid_request', `Duplicate sender address: "${email}"`);
|
|
307
314
|
seen.add(key);
|
|
308
315
|
const name = identity.name?.trim();
|
|
309
316
|
return { email, ...(name ? { name } : {}) };
|
|
@@ -322,13 +329,13 @@ export class EmailService {
|
|
|
322
329
|
async listMessages(accountId, q, page = {}) {
|
|
323
330
|
const normalized = normalizeEmailQuery(q);
|
|
324
331
|
if (!normalized.success) {
|
|
325
|
-
throw new
|
|
332
|
+
throw new ClientInputError('invalid_request', normalized.diagnostics.map((item) => item.message).join(' '), {
|
|
326
333
|
diagnostics: normalized.diagnostics,
|
|
327
334
|
});
|
|
328
335
|
}
|
|
329
336
|
const query = normalized.query;
|
|
330
337
|
if (page.includeSearchContext && !query.text) {
|
|
331
|
-
throw new
|
|
338
|
+
throw new ClientInputError('invalid_request', 'includeSearchContext requires a portable text query.');
|
|
332
339
|
}
|
|
333
340
|
const pageSize = Math.min(Math.max(page.pageSize ?? 25, 1), 100);
|
|
334
341
|
const ownsController = page.signal === undefined;
|
|
@@ -407,23 +414,23 @@ export class EmailService {
|
|
|
407
414
|
}
|
|
408
415
|
async searchMessagesBatch(input) {
|
|
409
416
|
if (input.accounts.length < 1 || input.accounts.length > 20) {
|
|
410
|
-
throw new
|
|
417
|
+
throw new ClientInputError('invalid_request', 'Batch search requires between 1 and 20 accounts.');
|
|
411
418
|
}
|
|
412
419
|
const seen = new Set();
|
|
413
420
|
for (const account of input.accounts) {
|
|
414
421
|
if (seen.has(account.accountId))
|
|
415
|
-
throw new
|
|
422
|
+
throw new ClientInputError('invalid_request', 'Batch account IDs must be distinct.');
|
|
416
423
|
seen.add(account.accountId);
|
|
417
424
|
}
|
|
418
425
|
const normalized = normalizeEmailQuery(input.query);
|
|
419
426
|
if (!normalized.success) {
|
|
420
|
-
throw new
|
|
427
|
+
throw new ClientInputError('invalid_request', normalized.diagnostics.map((item) => item.message).join(' '), {
|
|
421
428
|
diagnostics: normalized.diagnostics,
|
|
422
429
|
});
|
|
423
430
|
}
|
|
424
431
|
const pageSize = Math.min(Math.max(input.pageSize ?? 25, 1), 100);
|
|
425
432
|
if (input.includeSearchContext && !normalized.query.text) {
|
|
426
|
-
throw new
|
|
433
|
+
throw new ClientInputError('invalid_request', 'includeSearchContext requires a portable text query.');
|
|
427
434
|
}
|
|
428
435
|
const controller = new AbortController();
|
|
429
436
|
const startedAt = Date.now();
|
|
@@ -472,12 +479,14 @@ export class EmailService {
|
|
|
472
479
|
getThread(accountId, threadId) {
|
|
473
480
|
return this.withProvider(accountId, (p) => p.getThread(threadId));
|
|
474
481
|
}
|
|
475
|
-
createDraft(accountId, d) {
|
|
482
|
+
async createDraft(accountId, d) {
|
|
483
|
+
this.assertMailContent(d);
|
|
476
484
|
return this.withProvider(accountId, async (p, _id, account) => {
|
|
477
485
|
return p.createDraft(await this.resolveRecipients(p, account, d));
|
|
478
486
|
});
|
|
479
487
|
}
|
|
480
|
-
updateDraft(accountId, draftId, d) {
|
|
488
|
+
async updateDraft(accountId, draftId, d) {
|
|
489
|
+
this.assertMailContent(d);
|
|
481
490
|
return this.withProvider(accountId, async (p, _id, account) => {
|
|
482
491
|
return p.updateDraft(draftId, await this.resolveRecipients(p, account, d));
|
|
483
492
|
});
|
|
@@ -485,7 +494,125 @@ export class EmailService {
|
|
|
485
494
|
deleteDraft(accountId, draftId) {
|
|
486
495
|
return this.withProvider(accountId, (p) => p.deleteDraft(draftId));
|
|
487
496
|
}
|
|
488
|
-
|
|
497
|
+
getDraft(accountId, draftId) {
|
|
498
|
+
return this.withProvider(accountId, (p) => p.getDraft(draftId));
|
|
499
|
+
}
|
|
500
|
+
previewSend(accountId, input) {
|
|
501
|
+
return this.withProvider(accountId, async (provider, resolvedId, account) => {
|
|
502
|
+
if ('draftId' in input) {
|
|
503
|
+
const draft = await provider.getDraft(input.draftId);
|
|
504
|
+
if (draft.from?.email && draft.from.email.toLowerCase() !== account.email.toLowerCase()) {
|
|
505
|
+
await this.validateSender(provider, resolvedId, account, draft.from.email);
|
|
506
|
+
}
|
|
507
|
+
return {
|
|
508
|
+
accountId: resolvedId,
|
|
509
|
+
from: draft.from?.email ?? account.email,
|
|
510
|
+
to: draft.to,
|
|
511
|
+
cc: draft.cc ?? [],
|
|
512
|
+
bcc: draft.bcc ?? [],
|
|
513
|
+
subject: draft.subject,
|
|
514
|
+
attachments: (draft.attachments ?? []).map(({ filename, mimeType, sizeBytes }) => ({
|
|
515
|
+
filename,
|
|
516
|
+
mimeType,
|
|
517
|
+
sizeBytes,
|
|
518
|
+
})),
|
|
519
|
+
bodyTextChars: draft.body?.text?.length ?? 0,
|
|
520
|
+
bodyHtmlChars: draft.body?.html?.length ?? 0,
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
this.assertMailContent(input);
|
|
524
|
+
const resolved = await this.resolveRecipients(provider, account, input);
|
|
525
|
+
const subject = resolved.subject ??
|
|
526
|
+
(input.replyToMessageId ? replySubject((await provider.getMessage(input.replyToMessageId)).subject) : '');
|
|
527
|
+
return {
|
|
528
|
+
accountId: resolvedId,
|
|
529
|
+
from: resolved.from ?? account.email,
|
|
530
|
+
to: resolved.to ?? [],
|
|
531
|
+
cc: resolved.cc ?? [],
|
|
532
|
+
bcc: resolved.bcc ?? [],
|
|
533
|
+
subject,
|
|
534
|
+
attachments: (resolved.attachments ?? []).map(({ filename, mimeType, content }) => ({
|
|
535
|
+
filename,
|
|
536
|
+
mimeType,
|
|
537
|
+
sizeBytes: Buffer.byteLength(content, 'base64'),
|
|
538
|
+
})),
|
|
539
|
+
bodyTextChars: resolved.body.text?.length ?? 0,
|
|
540
|
+
bodyHtmlChars: resolved.body.html?.length ?? 0,
|
|
541
|
+
};
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
getDelivery(accountId, operationId) {
|
|
545
|
+
this.assertAccountAccess(accountId);
|
|
546
|
+
if (!this.principal)
|
|
547
|
+
throw new EmailError('permission_denied', 'A member session is required.');
|
|
548
|
+
return new DeliveryCoordinator(this.db).get(this.principal.principalId, this.principal.memberId, accountId, operationId);
|
|
549
|
+
}
|
|
550
|
+
deliver(accountId, input, key) {
|
|
551
|
+
const resolvedId = this.resolveScopedAccountId(accountId);
|
|
552
|
+
if (!this.principal)
|
|
553
|
+
throw new EmailError('permission_denied', 'A member session is required.');
|
|
554
|
+
return new DeliveryCoordinator(this.db).run({
|
|
555
|
+
principalId: this.principal.principalId,
|
|
556
|
+
memberId: this.principal.memberId,
|
|
557
|
+
accountId: resolvedId,
|
|
558
|
+
key,
|
|
559
|
+
kind: 'send',
|
|
560
|
+
request: input,
|
|
561
|
+
}, () => this.send(resolvedId, input));
|
|
562
|
+
}
|
|
563
|
+
deliverForward(accountId, input, key) {
|
|
564
|
+
const resolvedId = this.resolveScopedAccountId(accountId);
|
|
565
|
+
if (!this.principal)
|
|
566
|
+
throw new EmailError('permission_denied', 'A member session is required.');
|
|
567
|
+
return new DeliveryCoordinator(this.db).run({
|
|
568
|
+
principalId: this.principal.principalId,
|
|
569
|
+
memberId: this.principal.memberId,
|
|
570
|
+
accountId: resolvedId,
|
|
571
|
+
key,
|
|
572
|
+
kind: 'forward',
|
|
573
|
+
request: input,
|
|
574
|
+
}, () => this.forward(resolvedId, input));
|
|
575
|
+
}
|
|
576
|
+
deliverScheduled(accountId, draftId, scheduleId) {
|
|
577
|
+
return new DeliveryCoordinator(this.db).fireScheduled(accountId, draftId, scheduleId, () => this.withProvider(accountId, async (provider, resolvedId, account) => {
|
|
578
|
+
const draft = await provider.getDraft(draftId);
|
|
579
|
+
if (draft.from?.email && draft.from.email.toLowerCase() !== account.email.toLowerCase()) {
|
|
580
|
+
await this.validateSender(provider, resolvedId, account, draft.from.email);
|
|
581
|
+
}
|
|
582
|
+
}), () => this.withProvider(accountId, async (provider, resolvedId) => {
|
|
583
|
+
const result = await provider.send({ draftId });
|
|
584
|
+
const pending = findPendingByDraft(this.db, resolvedId, draftId);
|
|
585
|
+
if (pending) {
|
|
586
|
+
markSent(this.db, pending.id, result);
|
|
587
|
+
this.onScheduleChanged();
|
|
588
|
+
}
|
|
589
|
+
return result;
|
|
590
|
+
}));
|
|
591
|
+
}
|
|
592
|
+
scheduleDelivery(accountId, input, sendAt, key) {
|
|
593
|
+
const resolvedId = this.resolveScopedAccountId(accountId);
|
|
594
|
+
if (!this.principal)
|
|
595
|
+
throw new EmailError('permission_denied', 'A member session is required.');
|
|
596
|
+
let createdSchedule = false;
|
|
597
|
+
const scheduled = new DeliveryCoordinator(this.db).schedule({
|
|
598
|
+
principalId: this.principal.principalId,
|
|
599
|
+
memberId: this.principal.memberId,
|
|
600
|
+
accountId: resolvedId,
|
|
601
|
+
key,
|
|
602
|
+
request: { input, sendAt },
|
|
603
|
+
}, async () => {
|
|
604
|
+
const result = await this.createSchedule(resolvedId, input, sendAt);
|
|
605
|
+
createdSchedule = true;
|
|
606
|
+
return result;
|
|
607
|
+
});
|
|
608
|
+
return scheduled.finally(() => {
|
|
609
|
+
if (createdSchedule)
|
|
610
|
+
this.onScheduleChanged();
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
async send(accountId, input) {
|
|
614
|
+
if (!('draftId' in input))
|
|
615
|
+
this.assertMailContent(input);
|
|
489
616
|
return this.withProvider(accountId, async (p, resolvedId, account) => {
|
|
490
617
|
if ('draftId' in input) {
|
|
491
618
|
const draft = await p.getDraft(input.draftId);
|
|
@@ -509,6 +636,13 @@ export class EmailService {
|
|
|
509
636
|
* immediately; only the schedule (draft id + fire time) is stored locally.
|
|
510
637
|
*/
|
|
511
638
|
async scheduleSend(accountId, input, sendAtIso) {
|
|
639
|
+
const scheduled = await this.createSchedule(accountId, input, sendAtIso);
|
|
640
|
+
this.onScheduleChanged();
|
|
641
|
+
return scheduled;
|
|
642
|
+
}
|
|
643
|
+
async createSchedule(accountId, input, sendAtIso) {
|
|
644
|
+
if (!('draftId' in input))
|
|
645
|
+
this.assertMailContent(input);
|
|
512
646
|
const sendAt = resolveSendAt(sendAtIso);
|
|
513
647
|
const { draft, resolvedId } = await this.withProvider(accountId, async (p, id, account) => {
|
|
514
648
|
let message;
|
|
@@ -518,7 +652,7 @@ export class EmailService {
|
|
|
518
652
|
else {
|
|
519
653
|
const resolved = await this.resolveRecipients(p, account, input);
|
|
520
654
|
if (!resolved.to?.length && !resolved.cc?.length && !resolved.bcc?.length) {
|
|
521
|
-
throw new
|
|
655
|
+
throw new ClientInputError('invalid_request', 'Cannot schedule a message with no recipients');
|
|
522
656
|
}
|
|
523
657
|
message = await p.createDraft(resolved);
|
|
524
658
|
}
|
|
@@ -534,19 +668,23 @@ export class EmailService {
|
|
|
534
668
|
...(draft.subject !== undefined ? { subject: draft.subject } : {}),
|
|
535
669
|
...(draft.to?.length ? { toRecipients: formatAddressList(draft.to) } : {}),
|
|
536
670
|
});
|
|
537
|
-
this.onScheduleChanged();
|
|
538
671
|
return toScheduledInfo(row);
|
|
539
672
|
}
|
|
540
673
|
listScheduled(accountId) {
|
|
674
|
+
const coordinator = new DeliveryCoordinator(this.db);
|
|
675
|
+
const withOperation = (row) => {
|
|
676
|
+
const operationId = coordinator.findScheduled(row.accountId, row.id)?.operationId;
|
|
677
|
+
return { ...toScheduledInfo(row), ...(operationId ? { operationId } : {}) };
|
|
678
|
+
};
|
|
541
679
|
if (accountId !== undefined) {
|
|
542
|
-
return listScheduledSends(this.db, this.resolveScopedAccountId(accountId)).map(
|
|
680
|
+
return listScheduledSends(this.db, this.resolveScopedAccountId(accountId)).map(withOperation);
|
|
543
681
|
}
|
|
544
682
|
// Listing must work across accounts, but a member key only sees its own mailboxes'.
|
|
545
683
|
const rows = listScheduledSends(this.db);
|
|
546
684
|
if (this.isInternal())
|
|
547
|
-
return rows.map(
|
|
685
|
+
return rows.map(withOperation);
|
|
548
686
|
const accessible = new Set(this.accessibleAccounts().map((a) => a.id));
|
|
549
|
-
return rows.filter((r) => accessible.has(r.accountId)).map(
|
|
687
|
+
return rows.filter((r) => accessible.has(r.accountId)).map(withOperation);
|
|
550
688
|
}
|
|
551
689
|
/** Cancels a pending schedule; the provider draft is kept. */
|
|
552
690
|
cancelScheduled(scheduleId) {
|
|
@@ -556,11 +694,12 @@ export class EmailService {
|
|
|
556
694
|
throw new EmailError('not_found', `No scheduled send with id ${scheduleId}`);
|
|
557
695
|
}
|
|
558
696
|
if (row.status !== 'pending') {
|
|
559
|
-
throw new
|
|
697
|
+
throw new ClientInputError('invalid_request', `Scheduled send ${scheduleId} is already ${row.status}`);
|
|
560
698
|
}
|
|
561
699
|
if (!cancelScheduledSend(this.db, scheduleId)) {
|
|
562
|
-
throw new
|
|
700
|
+
throw new ClientInputError('invalid_request', `Scheduled send ${scheduleId} has already started sending`);
|
|
563
701
|
}
|
|
702
|
+
new DeliveryCoordinator(this.db).cancelScheduled(row.accountId, scheduleId);
|
|
564
703
|
this.onScheduleChanged();
|
|
565
704
|
return { scheduleId, draftId: row.draftId, draftKept: true };
|
|
566
705
|
}
|
|
@@ -624,7 +763,7 @@ export class EmailService {
|
|
|
624
763
|
async validateSender(provider, accountId, account, sender, known) {
|
|
625
764
|
const parsed = parseSingleAddress(sender.trim());
|
|
626
765
|
if (!parsed)
|
|
627
|
-
throw new
|
|
766
|
+
throw new ClientInputError('invalid_request', `Could not parse sender address: "${sender}"`);
|
|
628
767
|
if (parsed.email.toLowerCase() === account.email.toLowerCase()) {
|
|
629
768
|
return {
|
|
630
769
|
email: account.email,
|
|
@@ -635,7 +774,7 @@ export class EmailService {
|
|
|
635
774
|
}
|
|
636
775
|
const identity = (known ?? (await this.sendAsIdentities(provider, accountId, account))).find((candidate) => candidate.email.toLowerCase() === parsed.email.toLowerCase());
|
|
637
776
|
if (!identity)
|
|
638
|
-
throw new
|
|
777
|
+
throw new ClientInputError('invalid_request', `Sender address "${parsed.email}" is not available.`);
|
|
639
778
|
return identity;
|
|
640
779
|
}
|
|
641
780
|
selectReplySender(original, identities) {
|
|
@@ -650,13 +789,22 @@ export class EmailService {
|
|
|
650
789
|
}
|
|
651
790
|
return identities.find((identity) => identity.isPrimary) ?? identities[0];
|
|
652
791
|
}
|
|
653
|
-
forward(accountId, input) {
|
|
792
|
+
async forward(accountId, input) {
|
|
793
|
+
if (Buffer.byteLength(input.comment ?? '', 'utf8') > 1024 * 1024) {
|
|
794
|
+
throw new ClientInputError('invalid_request', 'Forward comment exceeds the 1 MiB body limit.');
|
|
795
|
+
}
|
|
654
796
|
return this.withProvider(accountId, async (p, resolvedId, account) => {
|
|
655
797
|
const original = await p.getMessage(input.messageId);
|
|
656
798
|
const includeAttachments = input.includeAttachments ?? true;
|
|
657
799
|
const attachments = [];
|
|
658
800
|
if (includeAttachments) {
|
|
659
|
-
|
|
801
|
+
const metadata = original.attachments ?? [];
|
|
802
|
+
if (metadata.length > 20)
|
|
803
|
+
throw new ClientInputError('invalid_request', 'A message may have at most 20 attachments.');
|
|
804
|
+
if (metadata.reduce((total, item) => total + item.sizeBytes, 0) > this.maxAttachmentBytes) {
|
|
805
|
+
throw new ClientInputError('invalid_request', 'Combined attachments exceed the configured size limit.');
|
|
806
|
+
}
|
|
807
|
+
for (const meta of metadata) {
|
|
660
808
|
const { content } = await p.getAttachment(original.id, meta.id);
|
|
661
809
|
attachments.push({
|
|
662
810
|
filename: meta.filename,
|
|
@@ -675,15 +823,73 @@ export class EmailService {
|
|
|
675
823
|
body: buildForwardBody(original, input.comment),
|
|
676
824
|
...(attachments.length ? { attachments } : {}),
|
|
677
825
|
};
|
|
826
|
+
this.assertMailContent(draft);
|
|
678
827
|
return p.send(draft);
|
|
679
828
|
});
|
|
680
829
|
}
|
|
681
|
-
modify(accountId, ids, action) {
|
|
682
|
-
|
|
830
|
+
async modify(accountId, ids, action) {
|
|
831
|
+
const unique = [...new Set(ids)];
|
|
832
|
+
if (unique.length < 1 || unique.length > 100) {
|
|
833
|
+
throw new ClientInputError('invalid_request', 'Modify between 1 and 100 distinct messages.');
|
|
834
|
+
}
|
|
835
|
+
const resolvedId = this.resolveScopedAccountId(accountId);
|
|
836
|
+
const result = await this.withProvider(resolvedId, async (provider) => {
|
|
837
|
+
const outcomes = Array.from({ length: unique.length }, () => 'uncertain');
|
|
838
|
+
let next = 0;
|
|
839
|
+
const worker = async () => {
|
|
840
|
+
for (;;) {
|
|
841
|
+
const index = next++;
|
|
842
|
+
if (index >= unique.length)
|
|
843
|
+
return;
|
|
844
|
+
try {
|
|
845
|
+
await provider.modify([unique[index]], action);
|
|
846
|
+
outcomes[index] = 'succeeded';
|
|
847
|
+
}
|
|
848
|
+
catch (error) {
|
|
849
|
+
outcomes[index] = isDefiniteDeliveryFailure(error)
|
|
850
|
+
? { code: isEmailError(error) ? error.code : 'internal' }
|
|
851
|
+
: 'uncertain';
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
};
|
|
855
|
+
await Promise.all(Array.from({ length: Math.min(4, unique.length) }, () => worker()));
|
|
856
|
+
return {
|
|
857
|
+
action,
|
|
858
|
+
succeededIds: unique.filter((_, index) => outcomes[index] === 'succeeded'),
|
|
859
|
+
failed: unique.flatMap((messageId, index) => {
|
|
860
|
+
const outcome = outcomes[index];
|
|
861
|
+
return typeof outcome === 'object' ? [{ messageId, code: outcome.code }] : [];
|
|
862
|
+
}),
|
|
863
|
+
uncertainIds: unique.filter((_, index) => outcomes[index] === 'uncertain'),
|
|
864
|
+
};
|
|
865
|
+
});
|
|
866
|
+
if (result.failed.some(({ code }) => code === 'auth_expired')) {
|
|
867
|
+
this.registry.markStatus(resolvedId, 'auth_error');
|
|
868
|
+
}
|
|
869
|
+
return result;
|
|
683
870
|
}
|
|
684
871
|
getAttachment(accountId, messageId, attachmentId, maxBytes) {
|
|
685
872
|
return this.withProvider(accountId, (p) => p.getAttachment(messageId, attachmentId, { maxBytes }));
|
|
686
873
|
}
|
|
874
|
+
assertMailContent(input) {
|
|
875
|
+
const bodyBytes = Buffer.byteLength(input.body.text ?? '', 'utf8') + Buffer.byteLength(input.body.html ?? '', 'utf8');
|
|
876
|
+
if (bodyBytes > 1024 * 1024) {
|
|
877
|
+
throw new ClientInputError('invalid_request', 'Text and HTML bodies together must be at most 1 MiB.');
|
|
878
|
+
}
|
|
879
|
+
const attachments = input.attachments ?? [];
|
|
880
|
+
if (attachments.length > 20)
|
|
881
|
+
throw new ClientInputError('invalid_request', 'A message may have at most 20 attachments.');
|
|
882
|
+
let total = 0;
|
|
883
|
+
for (const attachment of attachments) {
|
|
884
|
+
if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(attachment.content)) {
|
|
885
|
+
throw new ClientInputError('invalid_request', 'Attachment content must be base64.');
|
|
886
|
+
}
|
|
887
|
+
total += Buffer.byteLength(attachment.content, 'base64');
|
|
888
|
+
if (total > this.maxAttachmentBytes) {
|
|
889
|
+
throw new ClientInputError('invalid_request', 'Combined attachments exceed the configured size limit.');
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
}
|
|
687
893
|
}
|
|
688
894
|
export function buildForwardBody(original, comment) {
|
|
689
895
|
const headerLines = [
|