fix(monitoring): use a per-second ring buffer for DNS query metrics, improve DNS logging rate limiting and security event aggregation, and bump smartmta dependency
This commit is contained in:
10
changelog.md
10
changelog.md
@@ -1,5 +1,15 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-03-02 - 10.1.5 - fix(monitoring)
|
||||||
|
use a per-second ring buffer for DNS query metrics, improve DNS logging rate limiting and security event aggregation, and bump smartmta dependency
|
||||||
|
|
||||||
|
- Replace unbounded query timestamp array with a fixed-size per-second Int32Array ring buffer (300s) to calculate queries-per-second with O(1) updates and bounded memory
|
||||||
|
- Add incrementQueryRing and getQueryRingSum helpers to correctly zero stale slots and sum recent seconds
|
||||||
|
- Change metrics cache interval from 200ms to 1000ms to better match dashboard polling and reduce update frequency
|
||||||
|
- Refactor DNS adaptive logging to use per-second counters (dnsLogWindowSecond / dnsLogWindowCount) instead of timestamp arrays to avoid per-query array filtering and improve rate limiting accuracy; reset counters on flush
|
||||||
|
- Security logger: avoid mutating source when sorting/filtering, and implement single-pass aggregation with optional time-window filtering for byLevel/byType/top lists
|
||||||
|
- Bump dependency @push.rocks/smartmta from ^5.3.0 to ^5.3.1
|
||||||
|
|
||||||
## 2026-03-02 - 10.1.4 - fix(no-changes)
|
## 2026-03-02 - 10.1.4 - fix(no-changes)
|
||||||
no changes detected; no version bump required
|
no changes detected; no version bump required
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
"@push.rocks/smartlog": "^3.2.1",
|
"@push.rocks/smartlog": "^3.2.1",
|
||||||
"@push.rocks/smartmetrics": "^3.0.1",
|
"@push.rocks/smartmetrics": "^3.0.1",
|
||||||
"@push.rocks/smartmongo": "^5.1.0",
|
"@push.rocks/smartmongo": "^5.1.0",
|
||||||
"@push.rocks/smartmta": "^5.3.0",
|
"@push.rocks/smartmta": "^5.3.1",
|
||||||
"@push.rocks/smartnetwork": "^4.4.0",
|
"@push.rocks/smartnetwork": "^4.4.0",
|
||||||
"@push.rocks/smartpath": "^6.0.0",
|
"@push.rocks/smartpath": "^6.0.0",
|
||||||
"@push.rocks/smartpromise": "^4.2.3",
|
"@push.rocks/smartpromise": "^4.2.3",
|
||||||
|
|||||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -63,8 +63,8 @@ importers:
|
|||||||
specifier: ^5.1.0
|
specifier: ^5.1.0
|
||||||
version: 5.1.0(socks@2.8.7)
|
version: 5.1.0(socks@2.8.7)
|
||||||
'@push.rocks/smartmta':
|
'@push.rocks/smartmta':
|
||||||
specifier: ^5.3.0
|
specifier: ^5.3.1
|
||||||
version: 5.3.0
|
version: 5.3.1
|
||||||
'@push.rocks/smartnetwork':
|
'@push.rocks/smartnetwork':
|
||||||
specifier: ^4.4.0
|
specifier: ^4.4.0
|
||||||
version: 4.4.0
|
version: 4.4.0
|
||||||
@@ -999,8 +999,8 @@ packages:
|
|||||||
'@push.rocks/smartmongo@5.1.0':
|
'@push.rocks/smartmongo@5.1.0':
|
||||||
resolution: {integrity: sha512-2tpKf8K+SMdLHOEpafgKPIN+ypWTLwHc33hCUDNMQ1KaL7vokkavA44+fHxQydOGPMtDi22tSMFeVMCcUSzs4w==}
|
resolution: {integrity: sha512-2tpKf8K+SMdLHOEpafgKPIN+ypWTLwHc33hCUDNMQ1KaL7vokkavA44+fHxQydOGPMtDi22tSMFeVMCcUSzs4w==}
|
||||||
|
|
||||||
'@push.rocks/smartmta@5.3.0':
|
'@push.rocks/smartmta@5.3.1':
|
||||||
resolution: {integrity: sha512-uJI25fslzvrcenU36WCdt5gB8cCfkjUlY7PqlxEtFp474/l/kZxNnvirv1gnZLRNNa+ioe5aH18HKE+KcAjuxA==}
|
resolution: {integrity: sha512-cEuXO56i/zL9eZS79eAesEW16ikdBJKLlEv9pLKkt2cmaHBWADGHjeOzJmsszQ9CSFcuhd41aHYVGMZXVvsG2g==}
|
||||||
engines: {node: '>=14.0.0'}
|
engines: {node: '>=14.0.0'}
|
||||||
cpu: [x64, arm64]
|
cpu: [x64, arm64]
|
||||||
os: [darwin, linux, win32]
|
os: [darwin, linux, win32]
|
||||||
@@ -6247,7 +6247,7 @@ snapshots:
|
|||||||
- supports-color
|
- supports-color
|
||||||
- vue
|
- vue
|
||||||
|
|
||||||
'@push.rocks/smartmta@5.3.0':
|
'@push.rocks/smartmta@5.3.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@push.rocks/smartfile': 13.1.2
|
'@push.rocks/smartfile': 13.1.2
|
||||||
'@push.rocks/smartfs': 1.3.1
|
'@push.rocks/smartfs': 1.3.1
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '10.1.4',
|
version: '10.1.5',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -222,7 +222,8 @@ export class DcRouter {
|
|||||||
public detectedPublicIp: string | null = null;
|
public detectedPublicIp: string | null = null;
|
||||||
|
|
||||||
// DNS query logging rate limiter state
|
// DNS query logging rate limiter state
|
||||||
private dnsLogWindow: number[] = [];
|
private dnsLogWindowSecond: number = 0; // epoch second of current window
|
||||||
|
private dnsLogWindowCount: number = 0; // queries logged this second
|
||||||
private dnsBatchCount: number = 0;
|
private dnsBatchCount: number = 0;
|
||||||
private dnsBatchTimer: ReturnType<typeof setTimeout> | null = null;
|
private dnsBatchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
@@ -901,7 +902,8 @@ export class DcRouter {
|
|||||||
}
|
}
|
||||||
this.dnsBatchTimer = null;
|
this.dnsBatchTimer = null;
|
||||||
this.dnsBatchCount = 0;
|
this.dnsBatchCount = 0;
|
||||||
this.dnsLogWindow = [];
|
this.dnsLogWindowSecond = 0;
|
||||||
|
this.dnsLogWindowCount = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.opsServer.stop();
|
await this.opsServer.stop();
|
||||||
@@ -1312,11 +1314,14 @@ export class DcRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Adaptive logging: individual logs up to 2/sec, then batch
|
// Adaptive logging: individual logs up to 2/sec, then batch
|
||||||
const now = Date.now();
|
const nowSec = Math.floor(Date.now() / 1000);
|
||||||
this.dnsLogWindow = this.dnsLogWindow.filter(t => now - t < 1000);
|
if (nowSec !== this.dnsLogWindowSecond) {
|
||||||
|
this.dnsLogWindowSecond = nowSec;
|
||||||
|
this.dnsLogWindowCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.dnsLogWindow.length < 2) {
|
if (this.dnsLogWindowCount < 2) {
|
||||||
this.dnsLogWindow.push(now);
|
this.dnsLogWindowCount++;
|
||||||
const summary = event.questions.map(q => `${q.type} ${q.name}`).join(', ');
|
const summary = event.questions.map(q => `${q.type} ${q.name}`).join(', ');
|
||||||
logger.log('info', `DNS query: ${summary} (${event.responseTimeMs}ms, ${event.answered ? 'answered' : 'unanswered'})`, { zone: 'dns' });
|
logger.log('info', `DNS query: ${summary} (${event.responseTimeMs}ms, ${event.answered ? 'answered' : 'unanswered'})`, { zone: 'dns' });
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -35,7 +35,9 @@ export class MetricsManager {
|
|||||||
queryTypes: {} as Record<string, number>,
|
queryTypes: {} as Record<string, number>,
|
||||||
topDomains: new Map<string, number>(),
|
topDomains: new Map<string, number>(),
|
||||||
lastResetDate: new Date().toDateString(),
|
lastResetDate: new Date().toDateString(),
|
||||||
queryTimestamps: [] as number[], // Track query timestamps for rate calculation
|
// Per-second query count ring buffer (300 entries = 5 minutes)
|
||||||
|
queryRing: new Int32Array(300),
|
||||||
|
queryRingLastSecond: 0, // last epoch second that was written
|
||||||
responseTimes: [] as number[], // Track response times in ms
|
responseTimes: [] as number[], // Track response times in ms
|
||||||
recentQueries: [] as Array<{ timestamp: number; domain: string; type: string; answered: boolean; responseTimeMs: number }>,
|
recentQueries: [] as Array<{ timestamp: number; domain: string; type: string; answered: boolean; responseTimeMs: number }>,
|
||||||
};
|
};
|
||||||
@@ -95,7 +97,8 @@ export class MetricsManager {
|
|||||||
this.dnsMetrics.cacheMisses = 0;
|
this.dnsMetrics.cacheMisses = 0;
|
||||||
this.dnsMetrics.queryTypes = {};
|
this.dnsMetrics.queryTypes = {};
|
||||||
this.dnsMetrics.topDomains.clear();
|
this.dnsMetrics.topDomains.clear();
|
||||||
this.dnsMetrics.queryTimestamps = [];
|
this.dnsMetrics.queryRing.fill(0);
|
||||||
|
this.dnsMetrics.queryRingLastSecond = 0;
|
||||||
this.dnsMetrics.responseTimes = [];
|
this.dnsMetrics.responseTimes = [];
|
||||||
this.dnsMetrics.recentQueries = [];
|
this.dnsMetrics.recentQueries = [];
|
||||||
this.dnsMetrics.lastResetDate = currentDate;
|
this.dnsMetrics.lastResetDate = currentDate;
|
||||||
@@ -111,15 +114,6 @@ export class MetricsManager {
|
|||||||
this.securityMetrics.lastResetDate = currentDate;
|
this.securityMetrics.lastResetDate = currentDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prune old query timestamps (keep last 5 minutes)
|
|
||||||
const fiveMinutesAgo = Date.now() - 300000;
|
|
||||||
const idx = this.dnsMetrics.queryTimestamps.findIndex(ts => ts >= fiveMinutesAgo);
|
|
||||||
if (idx > 0) {
|
|
||||||
this.dnsMetrics.queryTimestamps = this.dnsMetrics.queryTimestamps.slice(idx);
|
|
||||||
} else if (idx === -1) {
|
|
||||||
this.dnsMetrics.queryTimestamps = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prune old time-series buckets every minute (don't wait for lazy query)
|
// Prune old time-series buckets every minute (don't wait for lazy query)
|
||||||
this.pruneOldBuckets();
|
this.pruneOldBuckets();
|
||||||
}, 60000); // Check every minute
|
}, 60000); // Check every minute
|
||||||
@@ -150,16 +144,16 @@ export class MetricsManager {
|
|||||||
const smartMetricsData = await this.smartMetrics.getMetrics();
|
const smartMetricsData = await this.smartMetrics.getMetrics();
|
||||||
const proxyMetrics = this.dcRouter.smartProxy ? this.dcRouter.smartProxy.getMetrics() : null;
|
const proxyMetrics = this.dcRouter.smartProxy ? this.dcRouter.smartProxy.getMetrics() : null;
|
||||||
const proxyStats = this.dcRouter.smartProxy ? await this.dcRouter.smartProxy.getStatistics() : null;
|
const proxyStats = this.dcRouter.smartProxy ? await this.dcRouter.smartProxy.getStatistics() : null;
|
||||||
|
const { heapUsed, heapTotal, external, rss } = process.memoryUsage();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
uptime: process.uptime(),
|
uptime: process.uptime(),
|
||||||
startTime: Date.now() - (process.uptime() * 1000),
|
startTime: Date.now() - (process.uptime() * 1000),
|
||||||
memoryUsage: {
|
memoryUsage: {
|
||||||
heapUsed: process.memoryUsage().heapUsed,
|
heapUsed,
|
||||||
heapTotal: process.memoryUsage().heapTotal,
|
heapTotal,
|
||||||
external: process.memoryUsage().external,
|
external,
|
||||||
rss: process.memoryUsage().rss,
|
rss,
|
||||||
// Add SmartMetrics memory data
|
|
||||||
maxMemoryMB: this.smartMetrics.maxMemoryMB,
|
maxMemoryMB: this.smartMetrics.maxMemoryMB,
|
||||||
actualUsageBytes: smartMetricsData.memoryUsageBytes,
|
actualUsageBytes: smartMetricsData.memoryUsageBytes,
|
||||||
actualUsagePercentage: smartMetricsData.memoryPercentage,
|
actualUsagePercentage: smartMetricsData.memoryPercentage,
|
||||||
@@ -228,11 +222,8 @@ export class MetricsManager {
|
|||||||
.slice(0, 10)
|
.slice(0, 10)
|
||||||
.map(([domain, count]) => ({ domain, count }));
|
.map(([domain, count]) => ({ domain, count }));
|
||||||
|
|
||||||
// Calculate queries per second from recent timestamps
|
// Calculate queries per second from ring buffer (sum last 60 seconds)
|
||||||
const now = Date.now();
|
const queriesPerSecond = this.getQueryRingSum(60) / 60;
|
||||||
const oneMinuteAgo = now - 60000;
|
|
||||||
const recentQueries = this.dnsMetrics.queryTimestamps.filter(ts => ts >= oneMinuteAgo);
|
|
||||||
const queriesPerSecond = recentQueries.length / 60;
|
|
||||||
|
|
||||||
// Calculate average response time
|
// Calculate average response time
|
||||||
const avgResponseTime = this.dnsMetrics.responseTimes.length > 0
|
const avgResponseTime = this.dnsMetrics.responseTimes.length > 0
|
||||||
@@ -436,8 +427,8 @@ export class MetricsManager {
|
|||||||
this.dnsMetrics.cacheMisses++;
|
this.dnsMetrics.cacheMisses++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track query timestamp (pruning moved to resetInterval to avoid O(n) per query)
|
// Increment per-second query counter in ring buffer
|
||||||
this.dnsMetrics.queryTimestamps.push(Date.now());
|
this.incrementQueryRing();
|
||||||
|
|
||||||
// Track response time if provided
|
// Track response time if provided
|
||||||
if (responseTimeMs) {
|
if (responseTimeMs) {
|
||||||
@@ -609,7 +600,7 @@ export class MetricsManager {
|
|||||||
requestsPerSecond,
|
requestsPerSecond,
|
||||||
requestsTotal,
|
requestsTotal,
|
||||||
};
|
};
|
||||||
}, 200); // Use 200ms cache for more frequent updates
|
}, 1000); // 1s cache — matches typical dashboard poll interval
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Time-series helpers ---
|
// --- Time-series helpers ---
|
||||||
@@ -638,6 +629,63 @@ export class MetricsManager {
|
|||||||
bucket.queries++;
|
bucket.queries++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increment the per-second query counter in the ring buffer.
|
||||||
|
* Zeros any stale slots between the last write and the current second.
|
||||||
|
*/
|
||||||
|
private incrementQueryRing(): void {
|
||||||
|
const currentSecond = Math.floor(Date.now() / 1000);
|
||||||
|
const ring = this.dnsMetrics.queryRing;
|
||||||
|
const last = this.dnsMetrics.queryRingLastSecond;
|
||||||
|
|
||||||
|
if (last === 0) {
|
||||||
|
// First call — zero and anchor
|
||||||
|
ring.fill(0);
|
||||||
|
this.dnsMetrics.queryRingLastSecond = currentSecond;
|
||||||
|
ring[currentSecond % ring.length] = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gap = currentSecond - last;
|
||||||
|
if (gap >= ring.length) {
|
||||||
|
// Entire ring is stale — clear all
|
||||||
|
ring.fill(0);
|
||||||
|
} else if (gap > 0) {
|
||||||
|
// Zero slots from (last+1) to currentSecond (inclusive)
|
||||||
|
for (let s = last + 1; s <= currentSecond; s++) {
|
||||||
|
ring[s % ring.length] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dnsMetrics.queryRingLastSecond = currentSecond;
|
||||||
|
ring[currentSecond % ring.length]++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sum query counts from the ring buffer for the last N seconds.
|
||||||
|
*/
|
||||||
|
private getQueryRingSum(seconds: number): number {
|
||||||
|
const currentSecond = Math.floor(Date.now() / 1000);
|
||||||
|
const ring = this.dnsMetrics.queryRing;
|
||||||
|
const last = this.dnsMetrics.queryRingLastSecond;
|
||||||
|
|
||||||
|
if (last === 0) return 0;
|
||||||
|
|
||||||
|
// First, zero stale slots so reads are accurate even without writes
|
||||||
|
const gap = currentSecond - last;
|
||||||
|
if (gap >= ring.length) return 0; // all data is stale
|
||||||
|
|
||||||
|
let sum = 0;
|
||||||
|
const limit = Math.min(seconds, ring.length);
|
||||||
|
for (let i = 0; i < limit; i++) {
|
||||||
|
const sec = currentSecond - i;
|
||||||
|
if (sec < last - (ring.length - 1)) break; // slot is from older cycle
|
||||||
|
if (sec > last) continue; // no writes yet for this second
|
||||||
|
sum += ring[sec % ring.length];
|
||||||
|
}
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
|
|
||||||
private pruneOldBuckets(): void {
|
private pruneOldBuckets(): void {
|
||||||
const cutoff = Date.now() - 86400000; // 24h
|
const cutoff = Date.now() - 86400000; // 24h
|
||||||
for (const key of this.emailMinuteBuckets.keys()) {
|
for (const key of this.emailMinuteBuckets.keys()) {
|
||||||
|
|||||||
@@ -162,8 +162,9 @@ export class SecurityLogger {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return most recent events up to limit
|
// Return most recent events up to limit (slice first to avoid mutating source)
|
||||||
return filteredEvents
|
return filteredEvents
|
||||||
|
.slice()
|
||||||
.sort((a, b) => b.timestamp - a.timestamp)
|
.sort((a, b) => b.timestamp - a.timestamp)
|
||||||
.slice(0, limit);
|
.slice(0, limit);
|
||||||
}
|
}
|
||||||
@@ -249,40 +250,34 @@ export class SecurityLogger {
|
|||||||
topIPs: Array<{ ip: string; count: number }>;
|
topIPs: Array<{ ip: string; count: number }>;
|
||||||
topDomains: Array<{ domain: string; count: number }>;
|
topDomains: Array<{ domain: string; count: number }>;
|
||||||
} {
|
} {
|
||||||
// Filter by time window if provided
|
const cutoff = timeWindow ? Date.now() - timeWindow : 0;
|
||||||
let events = this.securityEvents;
|
|
||||||
if (timeWindow) {
|
// Initialize counters
|
||||||
const cutoff = Date.now() - timeWindow;
|
const byLevel = {} as Record<SecurityLogLevel, number>;
|
||||||
events = events.filter(e => e.timestamp >= cutoff);
|
for (const level of Object.values(SecurityLogLevel)) {
|
||||||
|
byLevel[level] = 0;
|
||||||
|
}
|
||||||
|
const byType = {} as Record<SecurityEventType, number>;
|
||||||
|
for (const type of Object.values(SecurityEventType)) {
|
||||||
|
byType[type] = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count by level
|
|
||||||
const byLevel = Object.values(SecurityLogLevel).reduce((acc, level) => {
|
|
||||||
acc[level] = events.filter(e => e.level === level).length;
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<SecurityLogLevel, number>);
|
|
||||||
|
|
||||||
// Count by type
|
|
||||||
const byType = Object.values(SecurityEventType).reduce((acc, type) => {
|
|
||||||
acc[type] = events.filter(e => e.type === type).length;
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<SecurityEventType, number>);
|
|
||||||
|
|
||||||
// Count by IP
|
|
||||||
const ipCounts = new Map<string, number>();
|
const ipCounts = new Map<string, number>();
|
||||||
events.forEach(e => {
|
const domainCounts = new Map<string, number>();
|
||||||
|
|
||||||
|
// Single pass over all events
|
||||||
|
let total = 0;
|
||||||
|
for (const e of this.securityEvents) {
|
||||||
|
if (cutoff && e.timestamp < cutoff) continue;
|
||||||
|
total++;
|
||||||
|
byLevel[e.level]++;
|
||||||
|
byType[e.type]++;
|
||||||
if (e.ipAddress) {
|
if (e.ipAddress) {
|
||||||
ipCounts.set(e.ipAddress, (ipCounts.get(e.ipAddress) || 0) + 1);
|
ipCounts.set(e.ipAddress, (ipCounts.get(e.ipAddress) || 0) + 1);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// Count by domain
|
|
||||||
const domainCounts = new Map<string, number>();
|
|
||||||
events.forEach(e => {
|
|
||||||
if (e.domain) {
|
if (e.domain) {
|
||||||
domainCounts.set(e.domain, (domainCounts.get(e.domain) || 0) + 1);
|
domainCounts.set(e.domain, (domainCounts.get(e.domain) || 0) + 1);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
// Sort and limit top entries
|
// Sort and limit top entries
|
||||||
const topIPs = Array.from(ipCounts.entries())
|
const topIPs = Array.from(ipCounts.entries())
|
||||||
@@ -295,12 +290,6 @@ export class SecurityLogger {
|
|||||||
.sort((a, b) => b.count - a.count)
|
.sort((a, b) => b.count - a.count)
|
||||||
.slice(0, 10);
|
.slice(0, 10);
|
||||||
|
|
||||||
return {
|
return { total, byLevel, byType, topIPs, topDomains };
|
||||||
total: events.length,
|
|
||||||
byLevel,
|
|
||||||
byType,
|
|
||||||
topIPs,
|
|
||||||
topDomains
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '10.1.4',
|
version: '10.1.5',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user