feat: Mimecast email integration — message logs, threat events, 120d retention, admin UI
This commit is contained in:
parent
7792e91587
commit
25bb70cfa6
11 changed files with 112285 additions and 1 deletions
412
lib/services/mimecast-client.ts
Normal file
412
lib/services/mimecast-client.ts
Normal file
|
|
@ -0,0 +1,412 @@
|
|||
/**
|
||||
* Mimecast API 2.0 Client
|
||||
* Auth: OAuth2 Client Credentials — POST /oauth/token
|
||||
* Base: https://api.services.mimecast.com
|
||||
*
|
||||
* Endpoints sourced from official Postman collection (mimecast-api-v2-collection.json):
|
||||
* - Message logs: POST /api/message-finder/search (trackedEmails with pagination)
|
||||
* - SIEM batch: GET /siem/v1/batch/events/cg (returns pre-signed URLs → download JSON)
|
||||
* - Threat events: GET /threats/v1/events
|
||||
* - Message info: POST /api/message-finder/get-message-info
|
||||
* - Account: POST /api/account/get-account
|
||||
*/
|
||||
|
||||
export interface MimecastConfig {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
interface TokenResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
interface TokenCache {
|
||||
token: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export interface MimecastMessage {
|
||||
id: string;
|
||||
senderAddress: string;
|
||||
recipientAddress: string;
|
||||
subject: string;
|
||||
direction: string;
|
||||
status: string;
|
||||
action?: string;
|
||||
spamScore?: number;
|
||||
sizeBytes?: number;
|
||||
attachmentCount?: boolean | number;
|
||||
sentDateTime?: string;
|
||||
receivedDateTime?: string;
|
||||
route?: string;
|
||||
rejectReason?: string;
|
||||
heldReason?: string;
|
||||
sourceIp?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface MimecastThreatEvent {
|
||||
id: string;
|
||||
messageId?: string;
|
||||
eventType: string;
|
||||
threatLevel?: string;
|
||||
url?: string;
|
||||
fileName?: string;
|
||||
verdict?: string;
|
||||
actorEmail?: string;
|
||||
eventDateTime?: string;
|
||||
analysis?: string[];
|
||||
source?: string[];
|
||||
direction?: string[];
|
||||
status?: string[];
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface MimecastMessageInfo {
|
||||
messageId: string;
|
||||
bodyText?: string;
|
||||
bodyHtml?: string;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface PaginatedResult<T> {
|
||||
items: T[];
|
||||
nextCursor: string | null;
|
||||
totalCount?: number;
|
||||
}
|
||||
|
||||
export class MimecastClient {
|
||||
private readonly clientId: string;
|
||||
private readonly clientSecret: string;
|
||||
private readonly baseUrl: string;
|
||||
private tokenCache: TokenCache | null = null;
|
||||
|
||||
constructor(config: MimecastConfig) {
|
||||
this.clientId = config.clientId;
|
||||
this.clientSecret = config.clientSecret;
|
||||
this.baseUrl = (config.baseUrl ?? 'https://api.services.mimecast.com').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
// ── Auth ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private async getToken(): Promise<string> {
|
||||
if (this.tokenCache && Date.now() < this.tokenCache.expiresAt - 60_000) {
|
||||
return this.tokenCache.token;
|
||||
}
|
||||
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: this.clientId,
|
||||
client_secret: this.clientSecret,
|
||||
});
|
||||
|
||||
const res = await fetch(`${this.baseUrl}/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Mimecast auth failed (${res.status}): ${text}`);
|
||||
}
|
||||
|
||||
const data: TokenResponse = await res.json();
|
||||
this.tokenCache = {
|
||||
token: data.access_token,
|
||||
expiresAt: Date.now() + data.expires_in * 1000,
|
||||
};
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
method: 'GET' | 'POST',
|
||||
path: string,
|
||||
body?: Record<string, any>,
|
||||
params?: Record<string, string>
|
||||
): Promise<T> {
|
||||
const token = await this.getToken();
|
||||
|
||||
let url = `${this.baseUrl}${path}`;
|
||||
if (params && Object.keys(params).length > 0) {
|
||||
url = `${url}?${new URLSearchParams(params).toString()}`;
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Mimecast ${method} ${path} failed (${res.status}): ${text}`);
|
||||
}
|
||||
|
||||
return res.json() as T;
|
||||
}
|
||||
|
||||
// ── Message Tracking ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Search tracked emails via POST /api/message-finder/search
|
||||
* Response: { data: [{ trackedEmails: [...], pageToken: string }] }
|
||||
*/
|
||||
async getMessageLogs(options: {
|
||||
from: string; // ISO datetime string
|
||||
to: string;
|
||||
cursor?: string;
|
||||
pageSize?: number;
|
||||
}): Promise<PaginatedResult<MimecastMessage>> {
|
||||
const reqData: Record<string, any> = {
|
||||
from: options.from,
|
||||
to: options.to,
|
||||
pageSize: options.pageSize ?? 500,
|
||||
};
|
||||
if (options.cursor) reqData.pageToken = options.cursor;
|
||||
|
||||
const data = await this.request<any>('POST', '/api/message-finder/search', { data: [reqData] });
|
||||
|
||||
const block = data.data?.[0] ?? {};
|
||||
const tracked: any[] = block.trackedEmails ?? [];
|
||||
|
||||
return {
|
||||
items: tracked.map((m: any) => this.normalizeTrackedEmail(m)),
|
||||
nextCursor: block.pageToken ?? null,
|
||||
totalCount: tracked.length,
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeTrackedEmail(m: any): MimecastMessage {
|
||||
const sender = m.fromEnv?.emailAddress ?? m.fromHdr?.emailAddress ?? m.sender ?? '';
|
||||
const recipients: string[] = (m.to ?? []).map((t: any) =>
|
||||
typeof t === 'string' ? t : t.emailAddress ?? ''
|
||||
);
|
||||
|
||||
return {
|
||||
id: m.id,
|
||||
senderAddress: sender,
|
||||
recipientAddress: recipients.join(', '),
|
||||
subject: m.subject ?? '',
|
||||
direction: m.route?.toLowerCase().includes('inbound') ? 'inbound' : 'outbound',
|
||||
status: m.status ?? '',
|
||||
action: m.detectionLevel ?? undefined,
|
||||
spamScore: m.spamScore ?? undefined,
|
||||
sizeBytes: undefined,
|
||||
attachmentCount: m.attachments ?? 0,
|
||||
sentDateTime: m.sent ?? undefined,
|
||||
receivedDateTime: m.received ?? undefined,
|
||||
route: m.route ?? undefined,
|
||||
rejectReason: m.info ?? undefined,
|
||||
sourceIp: m.senderIP ?? undefined,
|
||||
_raw: m,
|
||||
};
|
||||
}
|
||||
|
||||
// ── SIEM Batch Events ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /siem/v1/batch/events/cg — returns pre-signed download URLs for batch NDJSON files
|
||||
* Each URL points to a compressed file; we download and parse each one.
|
||||
* Response: { value: [{url, expiry, size}], "@nextPage": string }
|
||||
*/
|
||||
async getSiemBatchEventUrls(options: {
|
||||
from: string; // date only: YYYY-MM-DD
|
||||
to: string;
|
||||
type?: string;
|
||||
cursor?: string;
|
||||
pageSize?: number;
|
||||
}): Promise<{ urls: Array<{ url: string; expiry: string; size: number }>; nextCursor: string | null }> {
|
||||
const params: Record<string, string> = {
|
||||
type: options.type ?? 'mailflow',
|
||||
dateRangeStartsAt: options.from,
|
||||
dateRangeEndsAt: options.to,
|
||||
pageSize: String(options.pageSize ?? 500),
|
||||
};
|
||||
if (options.cursor) params.pageToken = options.cursor;
|
||||
|
||||
const data = await this.request<any>('GET', '/siem/v1/batch/events/cg', undefined, params);
|
||||
|
||||
return {
|
||||
urls: data.value ?? [],
|
||||
nextCursor: data['@nextPage'] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Download and parse a SIEM batch event file (NDJSON, possibly gzipped)
|
||||
*/
|
||||
async downloadSiemBatchFile(url: string): Promise<MimecastMessage[]> {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return [];
|
||||
|
||||
const text = await res.text();
|
||||
const lines = text.split('\n').filter(l => l.trim());
|
||||
const messages: MimecastMessage[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const event = JSON.parse(line);
|
||||
messages.push(this.normalizeSiemEvent(event));
|
||||
} catch {
|
||||
// skip malformed lines
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
private normalizeSiemEvent(e: any): MimecastMessage {
|
||||
return {
|
||||
id: e.messageId ?? e.id ?? e.Messageid ?? '',
|
||||
senderAddress: e.senderAddress ?? e.Sender ?? e.sender ?? '',
|
||||
recipientAddress: e.recipientAddress ?? e.Recipient ?? e.recipient ?? '',
|
||||
subject: e.subject ?? e.Subject ?? '',
|
||||
direction: (e.Dir ?? e.direction ?? '').toLowerCase() === 'inbound' ? 'inbound' : 'outbound',
|
||||
status: e.Act ?? e.action ?? e.status ?? '',
|
||||
action: e.RejType ?? e.rejectionType ?? undefined,
|
||||
spamScore: e.SpamScore ?? e.spamScore ?? undefined,
|
||||
sizeBytes: e.MsgSize ?? e.messageSize ?? e.size ?? undefined,
|
||||
attachmentCount: e.AttCnt ?? e.attachmentCount ?? 0,
|
||||
sentDateTime: e.Datetime ?? e.datetime ?? e.timestamp ?? undefined,
|
||||
receivedDateTime: e.Datetime ?? undefined,
|
||||
route: e.Route ?? e.route ?? undefined,
|
||||
rejectReason: e.RejCode ?? e.rejectionCode ?? undefined,
|
||||
heldReason: e.HeldGroup ?? undefined,
|
||||
sourceIp: e.IP ?? e.senderIp ?? undefined,
|
||||
_raw: e,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Threat Events ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /threats/v1/events
|
||||
* Response: { value: [{timestamp, analysis, source, details, status, direction, subject, sender, ...}], "@nextPage": string }
|
||||
*/
|
||||
async getThreatEvents(options: {
|
||||
cursor?: string;
|
||||
pageSize?: number;
|
||||
} = {}): Promise<PaginatedResult<MimecastThreatEvent>> {
|
||||
const params: Record<string, string> = {
|
||||
pageSize: String(options.pageSize ?? 500),
|
||||
};
|
||||
if (options.cursor) params.pageToken = options.cursor;
|
||||
|
||||
try {
|
||||
const data = await this.request<any>('GET', '/threats/v1/events', undefined, params);
|
||||
const items = (data.value ?? []).map((e: any) => this.normalizeThreatEvent(e));
|
||||
return {
|
||||
items,
|
||||
nextCursor: data['@nextPage'] ?? null,
|
||||
};
|
||||
} catch {
|
||||
return { items: [], nextCursor: null };
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeThreatEvent(e: any): MimecastThreatEvent {
|
||||
const analysis: string[] = Array.isArray(e.analysis) ? e.analysis : [e.analysis].filter(Boolean);
|
||||
const eventType = analysis[0] ?? 'unknown';
|
||||
|
||||
const statusArr: string[] = Array.isArray(e.status) ? e.status : [e.status].filter(Boolean);
|
||||
const verdict = statusArr[0] ?? undefined;
|
||||
|
||||
const threatLevel = analysis.includes('malware') ? 'high'
|
||||
: analysis.includes('phishing') ? 'high'
|
||||
: analysis.includes('spam') ? 'medium'
|
||||
: 'info';
|
||||
|
||||
return {
|
||||
id: e.id ?? `threat_${e.sender ?? ''}_${e.timestamp ?? Date.now()}`,
|
||||
messageId: e.messageId ?? undefined,
|
||||
eventType,
|
||||
threatLevel,
|
||||
url: e.url ?? undefined,
|
||||
fileName: undefined,
|
||||
verdict,
|
||||
actorEmail: e.sender ?? undefined,
|
||||
eventDateTime: e.timestamp ?? undefined,
|
||||
analysis,
|
||||
source: Array.isArray(e.source) ? e.source : [e.source].filter(Boolean),
|
||||
direction: Array.isArray(e.direction) ? e.direction : [e.direction].filter(Boolean),
|
||||
status: statusArr,
|
||||
_raw: e,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Message Info (body/headers) ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /api/message-finder/get-message-info
|
||||
* Returns delivered message details including parts
|
||||
*/
|
||||
async getMessageInfo(messageId: string): Promise<MimecastMessageInfo | null> {
|
||||
try {
|
||||
const data = await this.request<any>('POST', '/api/message-finder/get-message-info', {
|
||||
data: [{ id: messageId }],
|
||||
});
|
||||
|
||||
const delivered = data.data?.[0]?.deliveredMessage;
|
||||
if (!delivered) return null;
|
||||
|
||||
const entries = Object.values(delivered) as any[];
|
||||
if (!entries.length) return null;
|
||||
|
||||
const info = entries[0]?.messageInfo ?? entries[0];
|
||||
|
||||
return {
|
||||
messageId,
|
||||
bodyText: info?.textBody ?? info?.body ?? undefined,
|
||||
bodyHtml: info?.htmlBody ?? undefined,
|
||||
headers: info?.headers ?? undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Account ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /api/account/get-account
|
||||
*/
|
||||
async testConnection(): Promise<{ ok: boolean; accountName?: string; packageName?: string; error?: string }> {
|
||||
try {
|
||||
const data = await this.request<any>('POST', '/api/account/get-account', { data: [{}] });
|
||||
const account = data.data?.[0] ?? {};
|
||||
return {
|
||||
ok: true,
|
||||
accountName: account.accountName ?? account.accountCode ?? 'Connected',
|
||||
packageName: account.packageName ?? undefined,
|
||||
};
|
||||
} catch (err: any) {
|
||||
return { ok: false, error: err.message };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _client: MimecastClient | null = null;
|
||||
|
||||
export function getMimecastClient(): MimecastClient {
|
||||
if (!_client) {
|
||||
const clientId = process.env.MIMECAST_CLIENT_ID;
|
||||
const clientSecret = process.env.MIMECAST_CLIENT_SECRET;
|
||||
if (!clientId || !clientSecret) {
|
||||
throw new Error('MIMECAST_CLIENT_ID and MIMECAST_CLIENT_SECRET must be set');
|
||||
}
|
||||
_client = new MimecastClient({
|
||||
clientId,
|
||||
clientSecret,
|
||||
baseUrl: process.env.MIMECAST_BASE_URL ?? 'https://api.services.mimecast.com',
|
||||
});
|
||||
}
|
||||
return _client;
|
||||
}
|
||||
331
lib/services/mimecast-sync-service.ts
Normal file
331
lib/services/mimecast-sync-service.ts
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
/**
|
||||
* Mimecast Sync Service
|
||||
* Full sync (120 days back), incremental (since last sync), body fetch, 120-day purge
|
||||
*/
|
||||
|
||||
import { getMimecastClient, MimecastMessage, MimecastThreatEvent } from './mimecast-client';
|
||||
import { postgresClient as pg } from './postgres-client';
|
||||
|
||||
export interface MimecastSyncResult {
|
||||
messagesUpserted: number;
|
||||
threatsUpserted: number;
|
||||
bodiesFetched: number;
|
||||
purgedMessages: number;
|
||||
errors: string[];
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
const RETENTION_DAYS = 120;
|
||||
const BODY_FETCH_LIMIT = 500;
|
||||
|
||||
// ── Upsert helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
async function upsertMessages(messages: MimecastMessage[]): Promise<number> {
|
||||
if (!messages.length) return 0;
|
||||
let count = 0;
|
||||
|
||||
for (const m of messages) {
|
||||
if (!m.id) continue;
|
||||
const senderDomain = m.senderAddress?.includes('@')
|
||||
? m.senderAddress.split('@')[1]?.toLowerCase()
|
||||
: null;
|
||||
|
||||
await pg.query(`
|
||||
INSERT INTO mimecast_messages (
|
||||
id, sender_address, sender_domain, recipient_address, subject,
|
||||
direction, status, action, spam_score, size_bytes, attachment_count,
|
||||
sent_datetime, received_datetime, delivery_datetime,
|
||||
route, reject_reason, held_reason, source_ip, raw, synced_at
|
||||
) VALUES (
|
||||
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,NOW()
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
status = EXCLUDED.status,
|
||||
action = EXCLUDED.action,
|
||||
delivery_datetime= EXCLUDED.delivery_datetime,
|
||||
reject_reason = EXCLUDED.reject_reason,
|
||||
held_reason = EXCLUDED.held_reason,
|
||||
raw = EXCLUDED.raw,
|
||||
synced_at = NOW()
|
||||
`, [
|
||||
m.id,
|
||||
m.senderAddress || null,
|
||||
senderDomain,
|
||||
m.recipientAddress || null,
|
||||
m.subject || null,
|
||||
m.direction || null,
|
||||
m.status || null,
|
||||
m.action || null,
|
||||
m.spamScore ?? null,
|
||||
m.sizeBytes ?? null,
|
||||
typeof m.attachmentCount === 'number' ? m.attachmentCount : null,
|
||||
m.sentDateTime || null,
|
||||
m.receivedDateTime || null,
|
||||
null, // delivery_datetime not in message-finder search
|
||||
m.route || null,
|
||||
m.rejectReason || null,
|
||||
m.heldReason || null,
|
||||
m.sourceIp || null,
|
||||
m._raw ? JSON.stringify(m._raw) : null,
|
||||
]);
|
||||
count++;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
async function upsertThreats(events: MimecastThreatEvent[]): Promise<number> {
|
||||
if (!events.length) return 0;
|
||||
let count = 0;
|
||||
|
||||
for (const e of events) {
|
||||
if (!e.id) continue;
|
||||
await pg.query(`
|
||||
INSERT INTO mimecast_threat_events (
|
||||
id, message_id, event_type, threat_level, url, file_name,
|
||||
verdict, actor_email, event_datetime, details, synced_at
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
threat_level = EXCLUDED.threat_level,
|
||||
verdict = EXCLUDED.verdict,
|
||||
details = EXCLUDED.details,
|
||||
synced_at = NOW()
|
||||
`, [
|
||||
e.id,
|
||||
e.messageId || null,
|
||||
e.eventType || null,
|
||||
e.threatLevel || null,
|
||||
e.url || null,
|
||||
e.fileName || null,
|
||||
e.verdict || null,
|
||||
e.actorEmail || null,
|
||||
e.eventDateTime || null,
|
||||
e._raw ? JSON.stringify(e._raw) : null,
|
||||
]);
|
||||
count++;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
async function fetchAndStoreBodies(errors: string[]): Promise<number> {
|
||||
const client = getMimecastClient();
|
||||
|
||||
// Find delivered inbound messages missing body, up to limit
|
||||
const rows = await pg.query(`
|
||||
SELECT m.id FROM mimecast_messages m
|
||||
LEFT JOIN mimecast_message_bodies b ON b.message_id = m.id
|
||||
WHERE b.message_id IS NULL
|
||||
AND m.status NOT IN ('rejected','spam','bounced')
|
||||
AND m.direction = 'inbound'
|
||||
ORDER BY m.sent_datetime DESC
|
||||
LIMIT $1
|
||||
`, [BODY_FETCH_LIMIT]);
|
||||
|
||||
let fetched = 0;
|
||||
for (const row of rows.rows) {
|
||||
try {
|
||||
const info = await client.getMessageInfo(row.id);
|
||||
if (!info) continue;
|
||||
|
||||
await pg.query(`
|
||||
INSERT INTO mimecast_message_bodies (message_id, body_text, body_html, headers, fetched_at)
|
||||
VALUES ($1, $2, $3, $4, NOW())
|
||||
ON CONFLICT (message_id) DO UPDATE SET
|
||||
body_text = EXCLUDED.body_text,
|
||||
body_html = EXCLUDED.body_html,
|
||||
headers = EXCLUDED.headers,
|
||||
fetched_at = NOW()
|
||||
`, [
|
||||
info.messageId,
|
||||
info.bodyText || null,
|
||||
info.bodyHtml || null,
|
||||
info.headers ? JSON.stringify(info.headers) : null,
|
||||
]);
|
||||
fetched++;
|
||||
} catch (err: any) {
|
||||
errors.push(`Body fetch ${row.id}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return fetched;
|
||||
}
|
||||
|
||||
async function purgeOldRecords(): Promise<number> {
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() - RETENTION_DAYS);
|
||||
|
||||
const result = await pg.query(
|
||||
`DELETE FROM mimecast_messages WHERE sent_datetime < $1`,
|
||||
[cutoff.toISOString()]
|
||||
);
|
||||
return result.rowCount ?? 0;
|
||||
}
|
||||
|
||||
async function getSyncState(syncType: string): Promise<{ lastSyncedAt: Date | null; cursor: string | null }> {
|
||||
const result = await pg.query(
|
||||
`SELECT last_synced_at, cursor FROM mimecast_sync_state WHERE sync_type = $1`,
|
||||
[syncType]
|
||||
);
|
||||
const row = result.rows[0];
|
||||
return {
|
||||
lastSyncedAt: row?.last_synced_at ? new Date(row.last_synced_at) : null,
|
||||
cursor: row?.cursor ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function saveSyncState(syncType: string, lastSyncedAt: Date, cursor: string | null): Promise<void> {
|
||||
await pg.query(`
|
||||
INSERT INTO mimecast_sync_state (sync_type, last_synced_at, cursor, updated_at)
|
||||
VALUES ($1, $2, $3, NOW())
|
||||
ON CONFLICT (sync_type) DO UPDATE SET
|
||||
last_synced_at = EXCLUDED.last_synced_at,
|
||||
cursor = EXCLUDED.cursor,
|
||||
updated_at = NOW()
|
||||
`, [syncType, lastSyncedAt.toISOString(), cursor]);
|
||||
}
|
||||
|
||||
// ── Main sync functions ───────────────────────────────────────────────────────
|
||||
|
||||
export async function syncMimecastMessages(
|
||||
fromDate: Date,
|
||||
toDate: Date,
|
||||
errors: string[]
|
||||
): Promise<number> {
|
||||
const client = getMimecastClient();
|
||||
let total = 0;
|
||||
let cursor: string | null = null;
|
||||
|
||||
const from = fromDate.toISOString();
|
||||
const to = toDate.toISOString();
|
||||
|
||||
do {
|
||||
try {
|
||||
const result = await client.getMessageLogs({ from, to, cursor: cursor ?? undefined, pageSize: 500 });
|
||||
if (result.items.length > 0) {
|
||||
total += await upsertMessages(result.items);
|
||||
}
|
||||
cursor = result.nextCursor;
|
||||
} catch (err: any) {
|
||||
errors.push(`Message sync page: ${err.message}`);
|
||||
break;
|
||||
}
|
||||
} while (cursor);
|
||||
|
||||
await saveSyncState('messages', toDate, null);
|
||||
return total;
|
||||
}
|
||||
|
||||
export async function syncMimecastThreats(errors: string[]): Promise<number> {
|
||||
const client = getMimecastClient();
|
||||
let total = 0;
|
||||
let cursor: string | null = null;
|
||||
|
||||
do {
|
||||
try {
|
||||
const result = await client.getThreatEvents({ cursor: cursor ?? undefined, pageSize: 500 });
|
||||
if (result.items.length > 0) {
|
||||
total += await upsertThreats(result.items);
|
||||
}
|
||||
cursor = result.nextCursor;
|
||||
} catch (err: any) {
|
||||
errors.push(`Threat sync: ${err.message}`);
|
||||
break;
|
||||
}
|
||||
} while (cursor);
|
||||
|
||||
await saveSyncState('threats', new Date(), null);
|
||||
return total;
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function runMimecastFullSync(): Promise<MimecastSyncResult> {
|
||||
const start = Date.now();
|
||||
const errors: string[] = [];
|
||||
|
||||
const toDate = new Date();
|
||||
const fromDate = new Date();
|
||||
fromDate.setDate(fromDate.getDate() - RETENTION_DAYS);
|
||||
|
||||
const messagesUpserted = await syncMimecastMessages(fromDate, toDate, errors);
|
||||
const threatsUpserted = await syncMimecastThreats(errors);
|
||||
const bodiesFetched = await fetchAndStoreBodies(errors);
|
||||
const purgedMessages = await purgeOldRecords();
|
||||
|
||||
return {
|
||||
messagesUpserted,
|
||||
threatsUpserted,
|
||||
bodiesFetched,
|
||||
purgedMessages,
|
||||
errors,
|
||||
durationMs: Date.now() - start,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runMimecastIncrementalSync(): Promise<MimecastSyncResult> {
|
||||
const start = Date.now();
|
||||
const errors: string[] = [];
|
||||
|
||||
const { lastSyncedAt } = await getSyncState('messages');
|
||||
|
||||
const toDate = new Date();
|
||||
const fromDate = lastSyncedAt ?? (() => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - 1);
|
||||
return d;
|
||||
})();
|
||||
|
||||
const messagesUpserted = await syncMimecastMessages(fromDate, toDate, errors);
|
||||
const threatsUpserted = await syncMimecastThreats(errors);
|
||||
const bodiesFetched = await fetchAndStoreBodies(errors);
|
||||
const purgedMessages = await purgeOldRecords();
|
||||
|
||||
return {
|
||||
messagesUpserted,
|
||||
threatsUpserted,
|
||||
bodiesFetched,
|
||||
purgedMessages,
|
||||
errors,
|
||||
durationMs: Date.now() - start,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getMimecastStats(): Promise<{
|
||||
messages: number;
|
||||
inbound: number;
|
||||
outbound: number;
|
||||
threats: number;
|
||||
bodies: number;
|
||||
lastSync: string | null;
|
||||
oldestMessage: string | null;
|
||||
}> {
|
||||
const [counts, syncState, oldest] = await Promise.all([
|
||||
pg.query(`
|
||||
SELECT
|
||||
COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE direction = 'inbound') AS inbound,
|
||||
COUNT(*) FILTER (WHERE direction = 'outbound') AS outbound
|
||||
FROM mimecast_messages
|
||||
`),
|
||||
pg.query(`SELECT last_synced_at FROM mimecast_sync_state WHERE sync_type = 'messages'`),
|
||||
pg.query(`SELECT MIN(sent_datetime) AS oldest FROM mimecast_messages`),
|
||||
]);
|
||||
|
||||
const [threatCount, bodyCount] = await Promise.all([
|
||||
pg.query(`SELECT COUNT(*) AS total FROM mimecast_threat_events`),
|
||||
pg.query(`SELECT COUNT(*) AS total FROM mimecast_message_bodies`),
|
||||
]);
|
||||
|
||||
const c = counts.rows[0] ?? {};
|
||||
|
||||
return {
|
||||
messages: Number(c.total ?? 0),
|
||||
inbound: Number(c.inbound ?? 0),
|
||||
outbound: Number(c.outbound ?? 0),
|
||||
threats: Number(threatCount.rows[0]?.total ?? 0),
|
||||
bodies: Number(bodyCount.rows[0]?.total ?? 0),
|
||||
lastSync: syncState.rows[0]?.last_synced_at ?? null,
|
||||
oldestMessage: oldest.rows[0]?.oldest ?? null,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue