@serve.zone/dcrouter 17.3.0 → 17.4.1
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/deno.json +1 -1
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.dcrouter.js +11 -2
- package/dist_ts/db/documents/classes.cached.email.d.ts +10 -1
- package/dist_ts/db/documents/classes.cached.email.js +27 -2
- package/dist_ts/email/classes.accepted-email-spool.d.ts +8 -1
- package/dist_ts/email/classes.accepted-email-spool.js +93 -1
- package/dist_ts/email/classes.email-domain.manager.js +3 -2
- package/dist_ts/email/classes.smartmta-storage-manager.d.ts +7 -1
- package/dist_ts/email/classes.smartmta-storage-manager.js +46 -1
- package/dist_ts/email/inbound-policy.d.ts +14 -0
- package/dist_ts/email/inbound-policy.js +17 -0
- package/dist_ts/email/smartmta-storage-policy.js +3 -1
- package/dist_ts/opsserver/handlers/email-ops.handler.d.ts +6 -0
- package/dist_ts/opsserver/handlers/email-ops.handler.js +40 -9
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/package.json +3 -3
- package/readme.plan.md +22 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.dcrouter.ts +10 -1
- package/ts/db/documents/classes.cached.email.ts +21 -1
- package/ts/email/classes.accepted-email-spool.ts +111 -0
- package/ts/email/classes.email-domain.manager.ts +2 -1
- package/ts/email/classes.smartmta-storage-manager.ts +57 -1
- package/ts/email/inbound-policy.ts +25 -0
- package/ts/email/smartmta-storage-policy.ts +2 -0
- package/ts/opsserver/handlers/email-ops.handler.ts +42 -8
- package/ts_web/00_commitinfo_data.ts +1 -1
package/ts/classes.dcrouter.ts
CHANGED
|
@@ -33,6 +33,7 @@ import { RouteConfigManager, ApiTokenManager, GatewayClientManager, ReferenceRes
|
|
|
33
33
|
import type { TVpnClientAllowEntry } from './config/classes.route-config-manager.js';
|
|
34
34
|
import { SecurityLogger, ContentScanner, IPReputationChecker, SecurityPolicyManager, RoutePolicyAugmenter } from './security/index.js';
|
|
35
35
|
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
|
|
36
|
+
import { applyDefaultInboundPolicy } from './email/inbound-policy.js';
|
|
36
37
|
import { DnsManager } from './dns/manager.dns.js';
|
|
37
38
|
import { DnsServerRuntime } from './dns/classes.dns-server-runtime.js';
|
|
38
39
|
import { GatewayRouteDnsReconciler } from './dns/classes.gateway-route-dns-reconciler.js';
|
|
@@ -1770,6 +1771,7 @@ export class DcRouter {
|
|
|
1770
1771
|
let emailConfig: IUnifiedEmailServerOptions = await this.workAppMailManager.applyStoredIdentitiesToEmailConfig({
|
|
1771
1772
|
...this.options.emailConfig,
|
|
1772
1773
|
ports: mappedEmailPorts,
|
|
1774
|
+
domains: applyDefaultInboundPolicy(this.options.emailConfig.domains),
|
|
1773
1775
|
dkimKeyProvisioning: 'caller-managed',
|
|
1774
1776
|
persistRoutes: this.options.emailConfig.persistRoutes ?? false,
|
|
1775
1777
|
queue: queueOptions,
|
|
@@ -1812,6 +1814,13 @@ export class DcRouter {
|
|
|
1812
1814
|
smtpMessage: configuredDecision?.smtpMessage ?? dcrouterDecision.smtpMessage,
|
|
1813
1815
|
};
|
|
1814
1816
|
},
|
|
1817
|
+
onAcceptEnvelope: async (context) => {
|
|
1818
|
+
const emailServer = this.emailServer;
|
|
1819
|
+
if (!emailServer) {
|
|
1820
|
+
throw new Error('Email server is not available for durable envelope acceptance');
|
|
1821
|
+
}
|
|
1822
|
+
await this.acceptedEmailSpool.acceptEnvelope(context, emailServer);
|
|
1823
|
+
},
|
|
1815
1824
|
},
|
|
1816
1825
|
});
|
|
1817
1826
|
|
|
@@ -1827,7 +1836,7 @@ export class DcRouter {
|
|
|
1827
1836
|
)) {
|
|
1828
1837
|
emailConfig = await this.workAppMailManager.applyStoredIdentitiesToEmailConfig({
|
|
1829
1838
|
...emailConfig,
|
|
1830
|
-
domains:
|
|
1839
|
+
domains: applyDefaultInboundPolicy(this.options.emailConfig?.domains),
|
|
1831
1840
|
});
|
|
1832
1841
|
// DkimManager captures its DomainRegistry at construction. Rebuild the
|
|
1833
1842
|
// unstarted server so per-domain repair failures are absent from
|
|
@@ -6,7 +6,7 @@ const TTL = plugins.smartdata.smartdataTtlValues;
|
|
|
6
6
|
/**
|
|
7
7
|
* Email status in the cache
|
|
8
8
|
*/
|
|
9
|
-
export type TCachedEmailStatus = 'pending' | 'processing' | 'queued' | 'delivered' | 'failed' | 'deferred';
|
|
9
|
+
export type TCachedEmailStatus = 'pending' | 'processing' | 'queued' | 'delivered' | 'failed' | 'deferred' | 'stored';
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Direction of an accepted email: 'inbound' = received from a remote peer,
|
|
@@ -141,6 +141,14 @@ export class CachedEmail extends plugins.smartdata.SmartdataCachedDocument<Cache
|
|
|
141
141
|
@plugins.smartdata.svDb()
|
|
142
142
|
public status!: TCachedEmailStatus;
|
|
143
143
|
|
|
144
|
+
/** Attachment count derived from the parsed message at acceptance. */
|
|
145
|
+
@plugins.smartdata.svDb()
|
|
146
|
+
public attachmentCount?: number;
|
|
147
|
+
|
|
148
|
+
/** JSON-serialized inbound SPF/DKIM/DMARC verdicts from the SMTP session. */
|
|
149
|
+
@plugins.smartdata.svDb()
|
|
150
|
+
public inboundSecurityResults?: string;
|
|
151
|
+
|
|
144
152
|
/**
|
|
145
153
|
* Number of delivery attempts
|
|
146
154
|
*/
|
|
@@ -344,6 +352,18 @@ export class CachedEmail extends plugins.smartdata.SmartdataCachedDocument<Cache
|
|
|
344
352
|
this.deliveredAt = new Date();
|
|
345
353
|
}
|
|
346
354
|
|
|
355
|
+
/**
|
|
356
|
+
* Mark as terminally stored (catch-all inbound): visible in the email log,
|
|
357
|
+
* pruned after the retention window.
|
|
358
|
+
*/
|
|
359
|
+
public markStored(retentionMs: number): void {
|
|
360
|
+
this.status = 'stored';
|
|
361
|
+
this.deliveredAt = new Date();
|
|
362
|
+
// Base-class TTL: the CacheCleaner deletes expired rows and their raw
|
|
363
|
+
// payloads via beforeDeleteCachedEmail.
|
|
364
|
+
this.setTTL(retentionMs);
|
|
365
|
+
}
|
|
366
|
+
|
|
347
367
|
/**
|
|
348
368
|
* Mark as failed with error
|
|
349
369
|
*/
|
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
} from '../db/documents/classes.cached.email.js';
|
|
8
8
|
import type {
|
|
9
9
|
Email,
|
|
10
|
+
IAcceptEnvelopeContext,
|
|
10
11
|
IExtendedSmtpSession,
|
|
11
12
|
IMessageAcceptanceContext,
|
|
12
13
|
IMessageAcceptanceDecision,
|
|
@@ -20,6 +21,8 @@ const ACCEPTED_EMAIL_RETRY_DELAY_MS = 5 * 60_000;
|
|
|
20
21
|
const ACCEPTED_EMAIL_QUEUE_LEASE_MS = 30 * 60_000;
|
|
21
22
|
const ACCEPTED_EMAIL_SPOOL_BATCH_SIZE = 25;
|
|
22
23
|
const ACCEPTED_EMAIL_STOP_DRAIN_TIMEOUT_MS = 30_000;
|
|
24
|
+
/** Retention for catch-all stored inbound mail (30 days). */
|
|
25
|
+
const INBOUND_STORE_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
|
23
26
|
|
|
24
27
|
export type TSmartMtaQueueItemLike = {
|
|
25
28
|
id?: string;
|
|
@@ -252,6 +255,114 @@ export class AcceptedEmailSpool {
|
|
|
252
255
|
};
|
|
253
256
|
}
|
|
254
257
|
|
|
258
|
+
/**
|
|
259
|
+
* Durable acceptance for envelopes containing at least one catch-all store
|
|
260
|
+
* route: anchors the exact raw message + metadata as a CachedEmail before
|
|
261
|
+
* SMTP 250, then dispatches any non-store recipients through SmartMTA's
|
|
262
|
+
* exact-plan executor. Store recipients are fulfilled by the anchor itself.
|
|
263
|
+
*/
|
|
264
|
+
public async acceptEnvelope(
|
|
265
|
+
context: IAcceptEnvelopeContext,
|
|
266
|
+
emailServer: UnifiedEmailServer,
|
|
267
|
+
): Promise<void> {
|
|
268
|
+
if (!this.dcRouterRef.dcRouterDb?.isReady()) {
|
|
269
|
+
throw new Error('DcRouterDb is not available for durable envelope acceptance');
|
|
270
|
+
}
|
|
271
|
+
this.throwIfMessageAcceptanceAborted(context.abortSignal);
|
|
272
|
+
|
|
273
|
+
const email = context.email;
|
|
274
|
+
const session = context.session;
|
|
275
|
+
const cachedEmail = CachedEmail.createNew();
|
|
276
|
+
this.removeHeader(email.headers, DCROUTER_CACHE_ID_HEADER);
|
|
277
|
+
email.headers[DCROUTER_CACHE_ID_HEADER] = cachedEmail.id;
|
|
278
|
+
cachedEmail.messageId = email.headers['Message-ID'] || email.headers['message-id'] || cachedEmail.id;
|
|
279
|
+
cachedEmail.from = context.envelope.mailFrom || email.from || '';
|
|
280
|
+
cachedEmail.to = context.envelope.rcptTo.length > 0
|
|
281
|
+
? [...context.envelope.rcptTo]
|
|
282
|
+
: Array.isArray(email.to) ? email.to : [];
|
|
283
|
+
cachedEmail.cc = Array.isArray(email.cc) ? email.cc : [];
|
|
284
|
+
cachedEmail.bcc = Array.isArray(email.bcc) ? email.bcc : [];
|
|
285
|
+
cachedEmail.subject = email.subject || '';
|
|
286
|
+
cachedEmail.attachmentCount = context.attachmentCount;
|
|
287
|
+
if (context.securityResults) {
|
|
288
|
+
cachedEmail.inboundSecurityResults = JSON.stringify(context.securityResults);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Build the exact per-recipient dispatch plan before durably committing,
|
|
292
|
+
// so an unplannable envelope is refused instead of half-anchored.
|
|
293
|
+
const idempotencyKeys = context.resolvedRecipientRoutes.map(
|
|
294
|
+
(resolution) => `${cachedEmail.id}:${resolution.recipient}`,
|
|
295
|
+
);
|
|
296
|
+
const plan = emailServer.createAcceptedEnvelopeDispatchPlan(context, idempotencyKeys);
|
|
297
|
+
const nonStoreEntries = plan.filter((entry) => entry.action.type !== 'store');
|
|
298
|
+
|
|
299
|
+
const persistedRawMessage = this.setDcRouterCacheIdHeader(
|
|
300
|
+
context.rawMessage.toString('utf8'),
|
|
301
|
+
cachedEmail.id,
|
|
302
|
+
);
|
|
303
|
+
await this.persistRawMessage(cachedEmail, persistedRawMessage);
|
|
304
|
+
cachedEmail.direction = session.authenticated ? 'outbound' : 'inbound';
|
|
305
|
+
cachedEmail.acceptedAt = Date.now();
|
|
306
|
+
cachedEmail.markStored(INBOUND_STORE_RETENTION_MS);
|
|
307
|
+
cachedEmail.routeData = JSON.stringify({
|
|
308
|
+
acceptedAt: new Date().toISOString(),
|
|
309
|
+
acceptance: 'durable-envelope',
|
|
310
|
+
recipientPlans: plan.map((entry) => ({
|
|
311
|
+
recipient: entry.recipient,
|
|
312
|
+
routeName: entry.routeName,
|
|
313
|
+
source: entry.source,
|
|
314
|
+
actionType: entry.action.type,
|
|
315
|
+
})),
|
|
316
|
+
session: {
|
|
317
|
+
id: session.id,
|
|
318
|
+
remoteAddress: session.remoteAddress,
|
|
319
|
+
clientHostname: session.clientHostname,
|
|
320
|
+
secure: !!session.secure,
|
|
321
|
+
authenticated: !!session.authenticated,
|
|
322
|
+
user: session.user,
|
|
323
|
+
envelope: context.envelope,
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
cachedEmail.updateSenderDomain();
|
|
327
|
+
cachedEmail.updateRecipientDomains();
|
|
328
|
+
try {
|
|
329
|
+
await cachedEmail.save();
|
|
330
|
+
await this.notifyEmailQueuePersisted(cachedEmail, 'envelope-stored');
|
|
331
|
+
} catch (error) {
|
|
332
|
+
await this.deleteRawMessage(cachedEmail).catch(() => undefined);
|
|
333
|
+
throw error;
|
|
334
|
+
}
|
|
335
|
+
if (context.abortSignal?.aborted) {
|
|
336
|
+
cachedEmail.markFailed('Envelope acceptance aborted before SMTP success');
|
|
337
|
+
await cachedEmail.save();
|
|
338
|
+
await this.notifyEmailQueuePersisted(cachedEmail, 'envelope-acceptance-aborted');
|
|
339
|
+
throw new Error('Envelope acceptance aborted before SMTP success');
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (nonStoreEntries.length > 0) {
|
|
343
|
+
const dispatchResult = await emailServer.dispatchAcceptedEnvelope(
|
|
344
|
+
context.rawMessage,
|
|
345
|
+
plan,
|
|
346
|
+
{
|
|
347
|
+
mailFrom: context.envelope.mailFrom,
|
|
348
|
+
session: {
|
|
349
|
+
id: session.id,
|
|
350
|
+
remoteAddress: session.remoteAddress,
|
|
351
|
+
clientHostname: session.clientHostname,
|
|
352
|
+
secure: !!session.secure,
|
|
353
|
+
authenticated: !!session.authenticated,
|
|
354
|
+
},
|
|
355
|
+
},
|
|
356
|
+
);
|
|
357
|
+
const failed = dispatchResult.results.filter(
|
|
358
|
+
(result) => result.status === 'failed' || result.status === 'rejected',
|
|
359
|
+
);
|
|
360
|
+
if (failed.length > 0) {
|
|
361
|
+
logger.log('warn', `Durable envelope ${cachedEmail.id}: ${failed.length}/${dispatchResult.results.length} non-store recipients failed dispatch: ${failed.map((result) => `${result.recipient}=${result.message || result.status}`).join('; ')}`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
255
366
|
/** Start the interval-driven spool processor and trigger an immediate run. */
|
|
256
367
|
public start(): void {
|
|
257
368
|
this.clearSpoolTimer();
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
MAIL_DNS_MAX_RETRY_INTERVAL_MS,
|
|
17
17
|
MAIL_DNS_RETRY_INTERVAL_MS,
|
|
18
18
|
} from './classes.mail-dns-sync.js';
|
|
19
|
+
import { applyDefaultInboundPolicy } from './inbound-policy.js';
|
|
19
20
|
import { projectMailDnsIntentStatus } from './mail-dns-status.js';
|
|
20
21
|
|
|
21
22
|
export interface IEmailDomainManagerActionResult {
|
|
@@ -884,7 +885,7 @@ export class EmailDomainManager {
|
|
|
884
885
|
}
|
|
885
886
|
mergedDomains.set(key, managedConfig);
|
|
886
887
|
}
|
|
887
|
-
const domains = [...mergedDomains.values()];
|
|
888
|
+
const domains = applyDefaultInboundPolicy([...mergedDomains.values()]);
|
|
888
889
|
this.dcRouter.options.emailConfig.domains = domains;
|
|
889
890
|
this.dcRouter.emailServer?.updateOptions({ domains });
|
|
890
891
|
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import * as plugins from '../plugins.js';
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
IStorageManager,
|
|
4
|
+
IStorageListPageOptions,
|
|
5
|
+
IStorageListPageResult,
|
|
6
|
+
} from '@push.rocks/smartmta';
|
|
3
7
|
import {
|
|
4
8
|
getSmartMtaTextNamespace,
|
|
5
9
|
hashSmartMtaStorageKey,
|
|
@@ -98,6 +102,58 @@ export class SmartMtaStorageManager implements IStorageManager {
|
|
|
98
102
|
.sort();
|
|
99
103
|
}
|
|
100
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Bounded, deletion-stable page listing: seeks strictly beyond the last
|
|
107
|
+
* returned key, so tokens stay valid while pages are deleted and an
|
|
108
|
+
* otherwise stable prefix is always exhausted.
|
|
109
|
+
*/
|
|
110
|
+
public async listPage(
|
|
111
|
+
prefix: string,
|
|
112
|
+
options: IStorageListPageOptions,
|
|
113
|
+
): Promise<IStorageListPageResult> {
|
|
114
|
+
const normalizedPrefix = normalizeSmartMtaStorageKey(prefix);
|
|
115
|
+
getSmartMtaTextNamespace(normalizedPrefix);
|
|
116
|
+
const requestedLimit = Number.isSafeInteger(options?.limit) && options.limit > 0
|
|
117
|
+
? options.limit
|
|
118
|
+
: 100;
|
|
119
|
+
const limit = Math.min(requestedLimit, 1000);
|
|
120
|
+
let seekAfter: string | undefined;
|
|
121
|
+
if (options?.continuationToken) {
|
|
122
|
+
const decoded = Buffer.from(options.continuationToken, 'base64url').toString('utf8');
|
|
123
|
+
if (!decoded.startsWith(normalizedPrefix)) {
|
|
124
|
+
throw new Error('SmartMTA storage continuation token does not match the requested prefix');
|
|
125
|
+
}
|
|
126
|
+
seekAfter = decoded;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const documents = await this.collection
|
|
130
|
+
.find({
|
|
131
|
+
key: {
|
|
132
|
+
...(seekAfter ? { $gt: seekAfter } : { $gte: normalizedPrefix }),
|
|
133
|
+
$lt: `${normalizedPrefix}`,
|
|
134
|
+
},
|
|
135
|
+
})
|
|
136
|
+
.sort({ key: 1 })
|
|
137
|
+
.limit(limit + 1)
|
|
138
|
+
.toArray();
|
|
139
|
+
|
|
140
|
+
const hasMore = documents.length > limit;
|
|
141
|
+
const pageDocuments = documents.slice(0, limit);
|
|
142
|
+
const keys = pageDocuments
|
|
143
|
+
.map((document: ISmartMtaStorageDocument) => this.validateDocument(document).key)
|
|
144
|
+
.filter((key: string) => key.startsWith(normalizedPrefix));
|
|
145
|
+
const lastKey = pageDocuments.length > 0
|
|
146
|
+
? (pageDocuments[pageDocuments.length - 1] as ISmartMtaStorageDocument).key
|
|
147
|
+
: undefined;
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
keys,
|
|
151
|
+
...(hasMore && lastKey
|
|
152
|
+
? { continuationToken: Buffer.from(lastKey, 'utf8').toString('base64url') }
|
|
153
|
+
: {}),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
101
157
|
public async delete(key: string): Promise<void> {
|
|
102
158
|
const normalizedKey = normalizeSmartMtaStorageKey(key);
|
|
103
159
|
getSmartMtaTextNamespace(normalizedKey);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Org default inbound policy: every email domain accepts inbound mail via
|
|
3
|
+
* catch-all store with bounded retention, so replies to platform-sent mail
|
|
4
|
+
* are never bounced by omission. Explicit SmartMTA routes always outrank
|
|
5
|
+
* this no-route fallback; explicit per-domain policies are preserved.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface IInboundPolicyLike {
|
|
9
|
+
defaultAction: 'store' | 'reject';
|
|
10
|
+
retentionDays: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_INBOUND_POLICY: IInboundPolicyLike = Object.freeze({
|
|
14
|
+
defaultAction: 'store',
|
|
15
|
+
retentionDays: 30,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export function applyDefaultInboundPolicy<T extends { inboundPolicy?: IInboundPolicyLike }>(
|
|
19
|
+
domainsArg: T[] | undefined,
|
|
20
|
+
): T[] {
|
|
21
|
+
return (domainsArg || []).map((domain) => ({
|
|
22
|
+
...domain,
|
|
23
|
+
inboundPolicy: domain.inboundPolicy ?? { ...DEFAULT_INBOUND_POLICY },
|
|
24
|
+
}));
|
|
25
|
+
}
|
|
@@ -11,6 +11,8 @@ export const smartMtaTextNamespaces: readonly ISmartMtaTextNamespace[] = Object.
|
|
|
11
11
|
{ prefix: '/email/bounces/', maxBytes: 8 * 1024 * 1024 },
|
|
12
12
|
{ prefix: '/email/templates/', maxBytes: 4 * 1024 * 1024 },
|
|
13
13
|
{ prefix: '/email/routes/config.json', maxBytes: 4 * 1024 * 1024 },
|
|
14
|
+
// SmartMTA 8.1+ accepted-envelope dispatch bookkeeping (idempotent replay records).
|
|
15
|
+
{ prefix: '/email/accepted-envelope-dispatch/', maxBytes: 64 * 1024 },
|
|
14
16
|
{ prefix: '/security/ip-reputation-cache.json', maxBytes: 8 * 1024 * 1024 },
|
|
15
17
|
{ prefix: '/workhosters/mail-identities.json', maxBytes: 4 * 1024 * 1024 },
|
|
16
18
|
]);
|
|
@@ -197,14 +197,7 @@ export class EmailOpsHandler {
|
|
|
197
197
|
authMethod: '',
|
|
198
198
|
authUser: this.extractSessionUser(session),
|
|
199
199
|
},
|
|
200
|
-
authenticationResults:
|
|
201
|
-
spf: 'none',
|
|
202
|
-
spfDomain: '',
|
|
203
|
-
dkim: 'none',
|
|
204
|
-
dkimDomain: '',
|
|
205
|
-
dmarc: 'none',
|
|
206
|
-
dmarcPolicy: '',
|
|
207
|
-
},
|
|
200
|
+
authenticationResults: this.mapInboundSecurityResults(doc.inboundSecurityResults),
|
|
208
201
|
rejectionReason: doc.status === 'failed' ? doc.lastError : undefined,
|
|
209
202
|
bounceMessage: doc.status === 'failed' ? doc.lastError : undefined,
|
|
210
203
|
headers,
|
|
@@ -388,6 +381,46 @@ export class EmailOpsHandler {
|
|
|
388
381
|
/**
|
|
389
382
|
* Map queue status to catalog TEmailStatus
|
|
390
383
|
*/
|
|
384
|
+
/**
|
|
385
|
+
* Maps SmartMTA inbound SPF/DKIM/DMARC verdicts (persisted as JSON on the
|
|
386
|
+
* cached email) into the catalog authenticationResults shape. Falls back to
|
|
387
|
+
* all-'none' when no verdicts were captured.
|
|
388
|
+
*/
|
|
389
|
+
private mapInboundSecurityResults(serializedArg?: string): interfaces.requests.IEmailDetail['authenticationResults'] {
|
|
390
|
+
const noneResult: interfaces.requests.IEmailDetail['authenticationResults'] = {
|
|
391
|
+
spf: 'none',
|
|
392
|
+
spfDomain: '',
|
|
393
|
+
dkim: 'none',
|
|
394
|
+
dkimDomain: '',
|
|
395
|
+
dmarc: 'none',
|
|
396
|
+
dmarcPolicy: '',
|
|
397
|
+
};
|
|
398
|
+
if (!serializedArg) return noneResult;
|
|
399
|
+
try {
|
|
400
|
+
const parsed = JSON.parse(serializedArg) as {
|
|
401
|
+
spf?: { result?: string; domain?: string } | null;
|
|
402
|
+
dkim?: Array<{ is_valid?: boolean; domain?: string | null; status?: string }> | null;
|
|
403
|
+
dmarc?: { passed?: boolean; policy?: string; domain?: string } | null;
|
|
404
|
+
};
|
|
405
|
+
const spfResult = (parsed.spf?.result || '').toLowerCase();
|
|
406
|
+
const spf = (['pass', 'fail', 'softfail', 'neutral'] as const).find((value) => value === spfResult) || 'none';
|
|
407
|
+
const dkimSignatures = Array.isArray(parsed.dkim) ? parsed.dkim : [];
|
|
408
|
+
const validSignature = dkimSignatures.find((signature) => signature?.is_valid);
|
|
409
|
+
const dkim = validSignature ? 'pass' : dkimSignatures.length > 0 ? 'fail' : 'none';
|
|
410
|
+
const dmarc = parsed.dmarc ? (parsed.dmarc.passed ? 'pass' : 'fail') : 'none';
|
|
411
|
+
return {
|
|
412
|
+
spf,
|
|
413
|
+
spfDomain: parsed.spf?.domain || '',
|
|
414
|
+
dkim,
|
|
415
|
+
dkimDomain: validSignature?.domain || dkimSignatures[0]?.domain || '',
|
|
416
|
+
dmarc,
|
|
417
|
+
dmarcPolicy: parsed.dmarc?.policy || '',
|
|
418
|
+
};
|
|
419
|
+
} catch {
|
|
420
|
+
return noneResult;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
391
424
|
private mapStatus(queueStatus: string): interfaces.requests.TEmailStatus {
|
|
392
425
|
switch (queueStatus) {
|
|
393
426
|
case 'pending':
|
|
@@ -395,6 +428,7 @@ export class EmailOpsHandler {
|
|
|
395
428
|
case 'queued':
|
|
396
429
|
return 'pending';
|
|
397
430
|
case 'delivered':
|
|
431
|
+
case 'stored':
|
|
398
432
|
return 'delivered';
|
|
399
433
|
case 'failed':
|
|
400
434
|
return 'bounced';
|