@serve.zone/coremail 1.1.0 → 1.3.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/changelog.md +43 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.auth.d.ts +19 -0
- package/dist_ts/classes.auth.js +58 -1
- package/dist_ts/classes.coremail.d.ts +3 -0
- package/dist_ts/classes.coremail.js +11 -4
- package/dist_ts/classes.gateway.d.ts +11 -1
- package/dist_ts/classes.gateway.js +101 -63
- package/dist_ts/classes.inbound.d.ts +10 -1
- package/dist_ts/classes.inbound.js +45 -7
- package/dist_ts/classes.maintenance.js +16 -1
- package/dist_ts/classes.models.d.ts +24 -2
- package/dist_ts/classes.models.js +72 -2
- package/dist_ts/classes.server.d.ts +6 -2
- package/dist_ts/classes.server.js +18 -68
- package/dist_ts/classes.smtpsubmission.d.ts +85 -0
- package/dist_ts/classes.smtpsubmission.js +379 -0
- package/dist_ts/classes.storage.d.ts +9 -0
- package/dist_ts/classes.storage.js +44 -1
- package/dist_ts/classes.submissions.d.ts +27 -1
- package/dist_ts/classes.submissions.js +164 -14
- package/dist_ts/coremail.errors.d.ts +18 -0
- package/dist_ts/coremail.errors.js +84 -0
- package/dist_ts/coremail.log.d.ts +1 -1
- package/dist_ts/coremail.log.js +1 -1
- package/dist_ts/coremail.mime.d.ts +15 -0
- package/dist_ts/coremail.mime.js +76 -1
- package/dist_ts/coremail.persistence.d.ts +38 -1
- package/dist_ts/coremail.persistence.js +124 -40
- package/dist_ts/coremail.stats.d.ts +72 -0
- package/dist_ts/coremail.stats.js +242 -0
- package/dist_ts/coremail.validation.d.ts +15 -2
- package/dist_ts/coremail.validation.js +26 -78
- package/dist_ts/interfaces.d.ts +18 -0
- package/dist_ts/plugins.d.ts +1 -0
- package/dist_ts/plugins.js +2 -1
- package/package.json +12 -11
- package/readme.md +51 -2
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.auth.ts +77 -0
- package/ts/classes.coremail.ts +20 -0
- package/ts/classes.gateway.ts +121 -66
- package/ts/classes.inbound.ts +71 -6
- package/ts/classes.maintenance.ts +14 -0
- package/ts/classes.models.ts +67 -1
- package/ts/classes.server.ts +23 -80
- package/ts/classes.smtpsubmission.ts +466 -0
- package/ts/classes.storage.ts +54 -0
- package/ts/classes.submissions.ts +247 -12
- package/ts/coremail.errors.ts +111 -0
- package/ts/coremail.log.ts +1 -0
- package/ts/coremail.mime.ts +89 -0
- package/ts/coremail.persistence.ts +196 -48
- package/ts/coremail.stats.ts +342 -0
- package/ts/coremail.validation.ts +31 -103
- package/ts/interfaces.ts +20 -0
- package/ts/plugins.ts +1 -0
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
import type { CoreMailAuth } from './classes.auth.js';
|
|
3
|
+
import { stableStringify } from './classes.gateway.js';
|
|
4
|
+
import type { CoreMailSubmissionService } from './classes.submissions.js';
|
|
5
|
+
import { logCoreMailFailure } from './coremail.log.js';
|
|
6
|
+
import { CoreMailQuotaExceededError } from './coremail.quota.js';
|
|
7
|
+
import type {
|
|
8
|
+
ICoreMailConfig,
|
|
9
|
+
ICoreMailWorkloadSession,
|
|
10
|
+
} from './interfaces.js';
|
|
11
|
+
|
|
12
|
+
type TDesiredState = plugins.serveZoneInterfaces.data.ICoreMailDesiredState;
|
|
13
|
+
type TSmtpDesired = plugins.serveZoneInterfaces.data.ICoreMailSmtpDesiredState;
|
|
14
|
+
type TBinding = plugins.serveZoneInterfaces.data.ICoreMailBindingDesiredState;
|
|
15
|
+
|
|
16
|
+
/** Session grants live no longer than smartmta's own hook-grant window. */
|
|
17
|
+
const sessionTtlMs = 60 * 60 * 1000;
|
|
18
|
+
/** Hard cap mirroring smartmta's hook-grant table. */
|
|
19
|
+
const sessionMaximum = 4_096;
|
|
20
|
+
const reconcileIntervalMs = 1_000;
|
|
21
|
+
/** First retry delay after a failed start. */
|
|
22
|
+
const startBackoffBaseMs = 1_000;
|
|
23
|
+
/** Ceiling for the consecutive-failure backoff. */
|
|
24
|
+
const startBackoffMaximumMs = 60_000;
|
|
25
|
+
|
|
26
|
+
interface ICoreMailSmtpSessionGrant {
|
|
27
|
+
session: ICoreMailWorkloadSession;
|
|
28
|
+
binding: TBinding;
|
|
29
|
+
expiresAt: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const waitForAbortableDelay = async (
|
|
33
|
+
delayMsArg: number,
|
|
34
|
+
signalArg: AbortSignal,
|
|
35
|
+
): Promise<void> => {
|
|
36
|
+
if (signalArg.aborted) return;
|
|
37
|
+
await new Promise<void>((resolveArg) => {
|
|
38
|
+
const timer = setTimeout(() => {
|
|
39
|
+
signalArg.removeEventListener('abort', onAbort);
|
|
40
|
+
resolveArg();
|
|
41
|
+
}, delayMsArg);
|
|
42
|
+
const onAbort = () => {
|
|
43
|
+
clearTimeout(timer);
|
|
44
|
+
resolveArg();
|
|
45
|
+
};
|
|
46
|
+
signalArg.addEventListener('abort', onAbort, { once: true });
|
|
47
|
+
});
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* CoreMail's SMTP submission listener (MSA).
|
|
52
|
+
*
|
|
53
|
+
* smartmta owns the transport only: it terminates TLS, runs SMTP AUTH over an
|
|
54
|
+
* encrypted channel, and hands every accepted message to `onMessageData` as
|
|
55
|
+
* the exact received bytes. Nothing is stored, queued or forwarded by it —
|
|
56
|
+
* `onMessageData` returns without `continueProcessing`, so the message ends at
|
|
57
|
+
* `CoreMailSubmissionService.submitRaw` and CoreMail's own durable outbound
|
|
58
|
+
* pipeline. The `MemoryStorageManager` handed to smartmta is therefore
|
|
59
|
+
* expected to stay empty for the life of the process; a test asserts that.
|
|
60
|
+
*
|
|
61
|
+
* The listener fails closed. It runs only when the desired state enables it
|
|
62
|
+
* and both PEM secrets resolve, because smartmta's Rust listener refuses AUTH
|
|
63
|
+
* on a cleartext session (538 5.7.11) and a listener without certificate
|
|
64
|
+
* material would advertise neither STARTTLS nor AUTH.
|
|
65
|
+
*/
|
|
66
|
+
export class CoreMailSmtpSubmissionServer {
|
|
67
|
+
private server: InstanceType<
|
|
68
|
+
typeof plugins.smartmta.UnifiedEmailServer
|
|
69
|
+
> | null = null;
|
|
70
|
+
private storageManager: InstanceType<
|
|
71
|
+
typeof plugins.smartmta.MemoryStorageManager
|
|
72
|
+
> | null = null;
|
|
73
|
+
private serverSignature = '';
|
|
74
|
+
private readonly stopController = new AbortController();
|
|
75
|
+
private reconcileTask: Promise<void> | null = null;
|
|
76
|
+
private readonly sessions = new Map<string, ICoreMailSmtpSessionGrant>();
|
|
77
|
+
private unavailableReason: string | null = 'CoreMail SMTP listener has not started.';
|
|
78
|
+
private loggedUnavailableSignature = '';
|
|
79
|
+
/** Signature of the configuration the last start was attempted for. */
|
|
80
|
+
private attemptedSignature = '';
|
|
81
|
+
private startFailures = 0;
|
|
82
|
+
private reportedUnavailable = 0;
|
|
83
|
+
private nextStartAttemptAt = 0;
|
|
84
|
+
|
|
85
|
+
public constructor(
|
|
86
|
+
private readonly config: ICoreMailConfig,
|
|
87
|
+
private readonly auth: CoreMailAuth,
|
|
88
|
+
private readonly submissions: CoreMailSubmissionService,
|
|
89
|
+
private readonly getDesiredState: () => Promise<TDesiredState | null>,
|
|
90
|
+
private readonly now: () => number = Date.now,
|
|
91
|
+
) {}
|
|
92
|
+
|
|
93
|
+
public start(): void {
|
|
94
|
+
this.reconcileTask ??= this.runReconciliationLoop();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
public async stop(): Promise<void> {
|
|
98
|
+
this.stopController.abort();
|
|
99
|
+
await this.reconcileTask?.catch(() => undefined);
|
|
100
|
+
this.reconcileTask = null;
|
|
101
|
+
await this.shutdownServer();
|
|
102
|
+
this.sessions.clear();
|
|
103
|
+
this.resetStartBackoff('');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Whether the listener is currently accepting submissions. */
|
|
107
|
+
public isReady(): boolean {
|
|
108
|
+
return this.server !== null && this.unavailableReason === null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Operator-facing view of the start-retry state. A listener that cannot bind
|
|
113
|
+
* (busy port, unusable key pair, missing mailer-bin) backs off instead of
|
|
114
|
+
* respawning the Rust bridge once per reconcile tick.
|
|
115
|
+
*/
|
|
116
|
+
public getListenerDiagnostics(): {
|
|
117
|
+
startFailures: number;
|
|
118
|
+
reportedUnavailable: number;
|
|
119
|
+
nextStartAttemptAt: number;
|
|
120
|
+
} {
|
|
121
|
+
return {
|
|
122
|
+
startFailures: this.startFailures,
|
|
123
|
+
reportedUnavailable: this.reportedUnavailable,
|
|
124
|
+
nextStartAttemptAt: this.nextStartAttemptAt,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** In-memory storage handed to smartmta, for the emptiness assertion. */
|
|
129
|
+
public getStorageManager(): InstanceType<
|
|
130
|
+
typeof plugins.smartmta.MemoryStorageManager
|
|
131
|
+
> | null {
|
|
132
|
+
return this.storageManager;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private async runReconciliationLoop(): Promise<void> {
|
|
136
|
+
while (!this.stopController.signal.aborted) {
|
|
137
|
+
try {
|
|
138
|
+
await this.reconcile();
|
|
139
|
+
} catch (error) {
|
|
140
|
+
await logCoreMailFailure('smtp', 'RECONCILIATION_FAILED', error)
|
|
141
|
+
.catch(() => undefined);
|
|
142
|
+
}
|
|
143
|
+
await waitForAbortableDelay(
|
|
144
|
+
reconcileIntervalMs,
|
|
145
|
+
this.stopController.signal,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private async reconcile(): Promise<void> {
|
|
151
|
+
const desired = await this.getDesiredState();
|
|
152
|
+
const smtp = desired?.smtp;
|
|
153
|
+
if (!smtp || !smtp.enabled) {
|
|
154
|
+
await this.markUnavailable(
|
|
155
|
+
smtp ? 'CoreMail SMTP listener is disabled.' : 'CoreMail SMTP listener is not configured.',
|
|
156
|
+
stableStringify(smtp ?? null),
|
|
157
|
+
);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const material = this.resolveTlsMaterial(smtp);
|
|
161
|
+
if (!material) {
|
|
162
|
+
await this.markUnavailable(
|
|
163
|
+
'CoreMail SMTP listener TLS material does not resolve.',
|
|
164
|
+
stableStringify(smtp),
|
|
165
|
+
);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const signature = stableStringify({ smtp, certPem: material.certPem });
|
|
169
|
+
if (this.server && signature === this.serverSignature) return;
|
|
170
|
+
if (signature === this.attemptedSignature) {
|
|
171
|
+
// The same configuration failed already; wait out its backoff rather
|
|
172
|
+
// than spawning and tearing down the Rust bridge every tick.
|
|
173
|
+
if (this.now() < this.nextStartAttemptAt) return;
|
|
174
|
+
} else {
|
|
175
|
+
// A changed smtp section always restarts immediately.
|
|
176
|
+
this.resetStartBackoff(signature);
|
|
177
|
+
}
|
|
178
|
+
await this.shutdownServer();
|
|
179
|
+
if (this.stopController.signal.aborted) return;
|
|
180
|
+
await this.startServer(smtp, material, signature);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private resetStartBackoff(signatureArg: string): void {
|
|
184
|
+
this.attemptedSignature = signatureArg;
|
|
185
|
+
this.startFailures = 0;
|
|
186
|
+
this.nextStartAttemptAt = 0;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Resolve both PEM secrets. A listener that cannot present a certificate
|
|
191
|
+
* must never come up: smartmta would then offer neither STARTTLS nor AUTH,
|
|
192
|
+
* and any client configured for submission would fail open into plaintext.
|
|
193
|
+
*/
|
|
194
|
+
private resolveTlsMaterial(
|
|
195
|
+
smtpArg: TSmtpDesired,
|
|
196
|
+
): { certPem: string; keyPem: string } | null {
|
|
197
|
+
try {
|
|
198
|
+
const certPem = this.config.resolveRuntimeSecret(
|
|
199
|
+
smtpArg.tls.certificatePemSecretKey,
|
|
200
|
+
);
|
|
201
|
+
const keyPem = this.config.resolveRuntimeSecret(
|
|
202
|
+
smtpArg.tls.privateKeyPemSecretKey,
|
|
203
|
+
);
|
|
204
|
+
if (
|
|
205
|
+
typeof certPem !== 'string'
|
|
206
|
+
|| !certPem.includes('BEGIN')
|
|
207
|
+
|| typeof keyPem !== 'string'
|
|
208
|
+
|| !keyPem.includes('BEGIN')
|
|
209
|
+
) {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
return { certPem, keyPem };
|
|
213
|
+
} catch {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Record why the listener is not serving and log it once per distinct
|
|
220
|
+
* reason. Repeating the same reason every reconcile tick would drown the
|
|
221
|
+
* operator in noise without adding information.
|
|
222
|
+
*/
|
|
223
|
+
private async reportUnavailable(
|
|
224
|
+
reasonArg: string,
|
|
225
|
+
signatureArg: string,
|
|
226
|
+
codeArg: 'LISTENER_UNAVAILABLE' | 'LISTENER_START_FAILED',
|
|
227
|
+
errorArg?: unknown,
|
|
228
|
+
): Promise<void> {
|
|
229
|
+
await this.shutdownServer();
|
|
230
|
+
this.unavailableReason = reasonArg;
|
|
231
|
+
const logSignature = `${codeArg}:${signatureArg}`;
|
|
232
|
+
if (this.loggedUnavailableSignature === logSignature) return;
|
|
233
|
+
this.loggedUnavailableSignature = logSignature;
|
|
234
|
+
this.reportedUnavailable += 1;
|
|
235
|
+
await logCoreMailFailure('smtp', codeArg, errorArg, {
|
|
236
|
+
reason: reasonArg,
|
|
237
|
+
}).catch(() => undefined);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The configuration is not asking for a listener right now, so no start is
|
|
242
|
+
* pending and a later enable must take effect on its first tick.
|
|
243
|
+
*/
|
|
244
|
+
private async markUnavailable(
|
|
245
|
+
reasonArg: string,
|
|
246
|
+
signatureArg: string,
|
|
247
|
+
): Promise<void> {
|
|
248
|
+
this.resetStartBackoff('');
|
|
249
|
+
await this.reportUnavailable(reasonArg, signatureArg, 'LISTENER_UNAVAILABLE');
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private async startServer(
|
|
253
|
+
smtpArg: TSmtpDesired,
|
|
254
|
+
materialArg: { certPem: string; keyPem: string },
|
|
255
|
+
signatureArg: string,
|
|
256
|
+
): Promise<void> {
|
|
257
|
+
const storageManager = new plugins.smartmta.MemoryStorageManager();
|
|
258
|
+
const server = new plugins.smartmta.UnifiedEmailServer({ storageManager }, {
|
|
259
|
+
ports: [smtpArg.port],
|
|
260
|
+
hostname: smtpArg.hostname,
|
|
261
|
+
// CoreMail hosts no inbound domain of its own here: this listener only
|
|
262
|
+
// relays authenticated submissions to the durable outbound pipeline.
|
|
263
|
+
domains: [],
|
|
264
|
+
routes: [{
|
|
265
|
+
name: 'submission',
|
|
266
|
+
match: { authenticated: true },
|
|
267
|
+
action: { type: 'process', allowRelay: true },
|
|
268
|
+
}],
|
|
269
|
+
persistRoutes: false,
|
|
270
|
+
dkimKeyProvisioning: 'caller-managed',
|
|
271
|
+
tls: { certPem: materialArg.certPem, keyPem: materialArg.keyPem },
|
|
272
|
+
auth: { required: true },
|
|
273
|
+
queue: { storageMode: 'memory' },
|
|
274
|
+
smtp: {
|
|
275
|
+
maxConcurrentMessages: 16,
|
|
276
|
+
recipientValidation: false,
|
|
277
|
+
authenticationTimeoutMs: 4_000,
|
|
278
|
+
messageAcceptanceTimeoutMs: 30_000,
|
|
279
|
+
},
|
|
280
|
+
maxMessageSize:
|
|
281
|
+
plugins.serveZoneInterfaces.data.coreMailLimits.serializedMimeBytes,
|
|
282
|
+
hooks: {
|
|
283
|
+
onAuthenticate: (contextArg) => this.onAuthenticate(contextArg),
|
|
284
|
+
onMessageData: (contextArg) => this.onMessageData(contextArg),
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
try {
|
|
288
|
+
await server.start();
|
|
289
|
+
} catch (error) {
|
|
290
|
+
await server.stop().catch(() => undefined);
|
|
291
|
+
this.startFailures += 1;
|
|
292
|
+
this.nextStartAttemptAt = this.now() + Math.min(
|
|
293
|
+
startBackoffMaximumMs,
|
|
294
|
+
startBackoffBaseMs * 2 ** (this.startFailures - 1),
|
|
295
|
+
);
|
|
296
|
+
await this.reportUnavailable(
|
|
297
|
+
'CoreMail SMTP listener failed to start.',
|
|
298
|
+
signatureArg,
|
|
299
|
+
'LISTENER_START_FAILED',
|
|
300
|
+
error,
|
|
301
|
+
);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
this.server = server;
|
|
305
|
+
this.storageManager = storageManager;
|
|
306
|
+
this.serverSignature = signatureArg;
|
|
307
|
+
this.unavailableReason = null;
|
|
308
|
+
this.loggedUnavailableSignature = '';
|
|
309
|
+
this.startFailures = 0;
|
|
310
|
+
this.nextStartAttemptAt = 0;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private async shutdownServer(): Promise<void> {
|
|
314
|
+
const server = this.server;
|
|
315
|
+
this.server = null;
|
|
316
|
+
this.serverSignature = '';
|
|
317
|
+
this.sessions.clear();
|
|
318
|
+
if (!server) return;
|
|
319
|
+
await server.stop().catch(async (error) => {
|
|
320
|
+
await logCoreMailFailure('smtp', 'LISTENER_SHUTDOWN_FAILED', error)
|
|
321
|
+
.catch(() => undefined);
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
private pruneSessions(): void {
|
|
326
|
+
const now = this.now();
|
|
327
|
+
for (const [sessionId, grant] of this.sessions) {
|
|
328
|
+
if (grant.expiresAt <= now) this.sessions.delete(sessionId);
|
|
329
|
+
}
|
|
330
|
+
while (this.sessions.size > sessionMaximum) {
|
|
331
|
+
const oldest = this.sessions.keys().next();
|
|
332
|
+
if (oldest.done) break;
|
|
333
|
+
this.sessions.delete(oldest.value);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private async onAuthenticate(
|
|
338
|
+
contextArg: plugins.smartmta.ISmtpAuthenticateContext,
|
|
339
|
+
): Promise<plugins.smartmta.ISmtpAuthenticateDecision> {
|
|
340
|
+
// The SMTP username is the binding id; the password is any accepted
|
|
341
|
+
// credential secret of that binding.
|
|
342
|
+
const authenticated = await this.auth.authenticateSmtpSubmission(
|
|
343
|
+
contextArg.username,
|
|
344
|
+
contextArg.password,
|
|
345
|
+
);
|
|
346
|
+
if (!authenticated) {
|
|
347
|
+
return { accepted: false, message: 'CoreMail binding credential rejected.' };
|
|
348
|
+
}
|
|
349
|
+
const { binding } = authenticated;
|
|
350
|
+
const session: ICoreMailWorkloadSession = {
|
|
351
|
+
kind: 'workload',
|
|
352
|
+
peerId: `smtp:${contextArg.sessionId}`,
|
|
353
|
+
tenantId: binding.tenantId,
|
|
354
|
+
serviceId: binding.serviceId,
|
|
355
|
+
bindingId: binding.bindingId,
|
|
356
|
+
bindingRevision: binding.revision,
|
|
357
|
+
configEpoch: authenticated.configEpoch,
|
|
358
|
+
credentialId: authenticated.credentialId,
|
|
359
|
+
credentialVersion: authenticated.credentialVersion,
|
|
360
|
+
bindingState: binding.state === 'draining' ? 'draining' : 'active',
|
|
361
|
+
capabilities: [...binding.capabilities],
|
|
362
|
+
allowedOperations: [
|
|
363
|
+
...plugins.serveZoneInterfaces.data.resolveCoreMailWorkloadOperations(
|
|
364
|
+
binding.state,
|
|
365
|
+
binding.capabilities,
|
|
366
|
+
),
|
|
367
|
+
],
|
|
368
|
+
coreMailTransferOrigin: '-',
|
|
369
|
+
authenticatedAt: this.now(),
|
|
370
|
+
};
|
|
371
|
+
this.sessions.delete(contextArg.sessionId);
|
|
372
|
+
this.sessions.set(contextArg.sessionId, {
|
|
373
|
+
session,
|
|
374
|
+
binding,
|
|
375
|
+
expiresAt: this.now() + sessionTtlMs,
|
|
376
|
+
});
|
|
377
|
+
this.pruneSessions();
|
|
378
|
+
return {
|
|
379
|
+
accepted: true,
|
|
380
|
+
// smartmta enforces the same sender scope CoreMail enforces again in
|
|
381
|
+
// submitRaw, so an out-of-scope MAIL FROM is refused at RCPT time.
|
|
382
|
+
scope: { senders: [...binding.allowedSenders] },
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
private async onMessageData(
|
|
387
|
+
contextArg: plugins.smartmta.IMessageAcceptanceContext,
|
|
388
|
+
): Promise<plugins.smartmta.IMessageAcceptanceDecision> {
|
|
389
|
+
const grant = this.sessions.get(contextArg.session.id);
|
|
390
|
+
if (!grant || grant.expiresAt <= this.now()) {
|
|
391
|
+
this.sessions.delete(contextArg.session.id);
|
|
392
|
+
return {
|
|
393
|
+
accepted: false,
|
|
394
|
+
smtpCode: 451,
|
|
395
|
+
smtpMessage: '4.7.1 Authentication scope unavailable; authenticate again',
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
try {
|
|
399
|
+
// smartmta strips the CRLF that preceded the DATA terminator; the
|
|
400
|
+
// durable raw queue requires canonical CRLF-terminated bytes.
|
|
401
|
+
const rawMime = new Uint8Array(contextArg.rawMessage.byteLength + 2);
|
|
402
|
+
rawMime.set(contextArg.rawMessage, 0);
|
|
403
|
+
rawMime.set([0x0d, 0x0a], contextArg.rawMessage.byteLength);
|
|
404
|
+
await this.submissions.submitRaw(grant.session, grant.binding, {
|
|
405
|
+
envelope: {
|
|
406
|
+
from: contextArg.session.envelope.mailFrom.address,
|
|
407
|
+
to: contextArg.session.envelope.rcptTo.map(
|
|
408
|
+
(recipientArg) => recipientArg.address,
|
|
409
|
+
),
|
|
410
|
+
},
|
|
411
|
+
rawMime,
|
|
412
|
+
});
|
|
413
|
+
// No continueProcessing: smartmta stores, queues and forwards nothing.
|
|
414
|
+
return { accepted: true };
|
|
415
|
+
} catch (error) {
|
|
416
|
+
return this.refusalFor(error);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
private refusalFor(
|
|
421
|
+
errorArg: unknown,
|
|
422
|
+
): plugins.smartmta.IMessageAcceptanceDecision {
|
|
423
|
+
if (errorArg instanceof CoreMailQuotaExceededError) {
|
|
424
|
+
return {
|
|
425
|
+
accepted: false,
|
|
426
|
+
smtpCode: 452,
|
|
427
|
+
smtpMessage: '4.2.1 Mail quota exceeded; try again later',
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
const message = errorArg instanceof Error ? errorArg.message : '';
|
|
431
|
+
if (message.includes('sender is not authorized')) {
|
|
432
|
+
return {
|
|
433
|
+
accepted: false,
|
|
434
|
+
smtpCode: 553,
|
|
435
|
+
smtpMessage: '5.7.1 Sender address not permitted',
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
if (message.includes('byte budget')) {
|
|
439
|
+
return {
|
|
440
|
+
accepted: false,
|
|
441
|
+
smtpCode: 552,
|
|
442
|
+
smtpMessage: '5.3.4 Message exceeds its size limit',
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
if (
|
|
446
|
+
errorArg instanceof Error
|
|
447
|
+
&& (
|
|
448
|
+
errorArg.name === 'CoreMailCapabilityDeniedError'
|
|
449
|
+
|| errorArg.name === 'CoreMailAuthorizationError'
|
|
450
|
+
)
|
|
451
|
+
) {
|
|
452
|
+
return {
|
|
453
|
+
accepted: false,
|
|
454
|
+
smtpCode: 550,
|
|
455
|
+
smtpMessage: '5.7.1 Submission is not permitted by this authority',
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
void logCoreMailFailure('smtp', 'SUBMISSION_FAILED', errorArg)
|
|
459
|
+
.catch(() => undefined);
|
|
460
|
+
return {
|
|
461
|
+
accepted: false,
|
|
462
|
+
smtpCode: 451,
|
|
463
|
+
smtpMessage: '4.3.0 Submission temporarily unavailable',
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
}
|
package/ts/classes.storage.ts
CHANGED
|
@@ -372,6 +372,60 @@ export class CoreMailStorage {
|
|
|
372
372
|
);
|
|
373
373
|
}
|
|
374
374
|
|
|
375
|
+
/**
|
|
376
|
+
* Read at most `headBytesArg` leading bytes of a stored object.
|
|
377
|
+
*
|
|
378
|
+
* The object's integrity was already proven when its transfer grant was
|
|
379
|
+
* consumed, so a header scan does not need to re-hash the whole payload — it
|
|
380
|
+
* reads a bounded prefix and cancels. The verified full-object readers above
|
|
381
|
+
* remain the only way to obtain content that is acted on as a whole.
|
|
382
|
+
*/
|
|
383
|
+
public async readObjectHeadBytes(
|
|
384
|
+
objectKeyArg: string,
|
|
385
|
+
lengthBytesArg: number,
|
|
386
|
+
sha256Arg: TCoreMailSha256,
|
|
387
|
+
maximumBytesArg: number,
|
|
388
|
+
headBytesArg: number,
|
|
389
|
+
signalArg?: AbortSignal,
|
|
390
|
+
): Promise<Uint8Array> {
|
|
391
|
+
if (!Number.isSafeInteger(headBytesArg) || headBytesArg < 1) {
|
|
392
|
+
throw new Error('CoreMail object head read length is invalid.');
|
|
393
|
+
}
|
|
394
|
+
const verified = await this.getVerifiedStream(
|
|
395
|
+
objectKeyArg,
|
|
396
|
+
lengthBytesArg,
|
|
397
|
+
sha256Arg,
|
|
398
|
+
maximumBytesArg,
|
|
399
|
+
signalArg,
|
|
400
|
+
);
|
|
401
|
+
const reader = verified.stream.getReader();
|
|
402
|
+
const chunks: Uint8Array[] = [];
|
|
403
|
+
let total = 0;
|
|
404
|
+
try {
|
|
405
|
+
while (total < headBytesArg) {
|
|
406
|
+
const next = await reader.read();
|
|
407
|
+
if (next.done) break;
|
|
408
|
+
chunks.push(next.value);
|
|
409
|
+
total += next.value.byteLength;
|
|
410
|
+
}
|
|
411
|
+
} finally {
|
|
412
|
+
await reader.cancel().catch(() => undefined);
|
|
413
|
+
reader.releaseLock();
|
|
414
|
+
await verified.cancel().catch(() => undefined);
|
|
415
|
+
// The digest is deliberately not awaited: only a prefix was consumed.
|
|
416
|
+
verified.completion.catch(() => undefined);
|
|
417
|
+
}
|
|
418
|
+
const head = new Uint8Array(Math.min(total, headBytesArg));
|
|
419
|
+
let offset = 0;
|
|
420
|
+
for (const chunk of chunks) {
|
|
421
|
+
if (offset >= head.byteLength) break;
|
|
422
|
+
const slice = chunk.subarray(0, head.byteLength - offset);
|
|
423
|
+
head.set(slice, offset);
|
|
424
|
+
offset += slice.byteLength;
|
|
425
|
+
}
|
|
426
|
+
return head;
|
|
427
|
+
}
|
|
428
|
+
|
|
375
429
|
public async getVerifiedBytes(
|
|
376
430
|
objectKeyArg: string,
|
|
377
431
|
lengthBytesArg: number,
|