/** * 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; accountCode?: 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; } export interface MimecastHeldMessage { id: string; subject: string; from: string; fromDisplay: string; to: string; toDisplay: string; dateReceived: string; reason: string; reasonCode: string; policyInfo: string; route: string; hasAttachments: boolean; size: number; } export interface MimecastCloudUser { emailAddress: string; domain: string; lockedOut: boolean; status?: string; name?: string; alias?: string[]; [key: string]: any; } export interface PaginatedResult { items: T[]; nextCursor: string | null; totalCount?: number; } export class MimecastClient { private readonly clientId: string; private readonly clientSecret: string; private readonly baseUrl: string; private readonly accountCode: 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(/\/$/, ''); this.accountCode = config.accountCode ?? ''; } /** * Format a Date to the Mimecast-required format: yyyy-MM-ddTHH:mm:ss+0000 * Note: NO milliseconds — the API rejects .SSS variants */ static formatDate(d: Date): string { const pad = (n: number) => String(n).padStart(2, '0'); return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}` + `T${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}+0000`; } // ── Auth ───────────────────────────────────────────────────────────────── private async getToken(): Promise { 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( method: 'GET' | 'POST', path: string, body?: Record, params?: Record ): Promise { const token = await this.getToken(); let url = `${this.baseUrl}${path}`; if (params && Object.keys(params).length > 0) { url = `${url}?${new URLSearchParams(params).toString()}`; } const headers: Record = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', Accept: 'application/json', }; if (this.accountCode) { headers['x-mc-account'] = this.accountCode; } const res = await fetch(url, { method, headers, 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: Date; to: Date; cursor?: string; pageSize?: number; fromFilter?: string; toFilter?: string; }): Promise> { const atOptions: Record = {}; if (options.fromFilter) atOptions.from = options.fromFilter; if (options.toFilter) atOptions.to = options.toFilter; const reqData: Record = { accountCode: this.accountCode, advancedTrackAndTraceOptions: atOptions, start: MimecastClient.formatDate(options.from), end: MimecastClient.formatDate(options.to), }; if (options.cursor) reqData.pageToken = options.cursor; const data = await this.request('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 = { 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('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 { 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> { const params: Record = { pageSize: String(options.pageSize ?? 500), }; if (options.cursor) params.pageToken = options.cursor; try { const data = await this.request('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 { try { const data = await this.request('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; } } // ── Cloud Gateway ───────────────────────────────────────────────────────── /** * GET /user/cloud-gateway/v1/users?emailAddress=...&domain=... */ async getCloudUser(emailAddress: string, domain: string): Promise { const data = await this.request('GET', '/user/cloud-gateway/v1/users', undefined, { emailAddress, domain, }); const user = data?.value?.[0] ?? data?.data?.[0] ?? null; if (!user) return null; return { emailAddress: user.emailAddress ?? emailAddress, domain: user.domain ?? domain, lockedOut: user.lockedOut ?? false, status: user.status ?? undefined, name: user.name ?? undefined, alias: user.alias ?? undefined, _raw: user, }; } // ── Held Messages ────────────────────────────────────────────────────────── /** * POST /api/gateway/get-hold-message-list * admin: true = see user-level hold queues as an admin * pageSize is ignored by the API (always returns 10); paginate via meta.pagination.next * Fetches ALL pages up to maxMessages limit. */ async getHeldMessages(options: { recipient?: string; maxMessages?: number; } = {}): Promise<{ messages: MimecastHeldMessage[]; totalCount: number }> { const maxMessages = options.maxMessages ?? 500; const all: MimecastHeldMessage[] = []; let cursor: string | null = null; let totalCount = 0; do { const reqBody: any = { admin: true }; if (options.recipient) { reqBody.searchBy = { fieldName: 'recipient', value: options.recipient }; } const body: any = { data: [reqBody] }; if (cursor) { body.meta = { pagination: { pageToken: cursor } }; } const result = await this.request('POST', '/api/gateway/get-hold-message-list', body); const pagination = result?.meta?.pagination ?? {}; const msgs: any[] = result?.data ?? []; if (totalCount === 0) totalCount = pagination.totalCount ?? msgs.length; for (const m of msgs) { all.push({ id: m.id, subject: m.subject ?? '', from: m.fromHeader?.emailAddress ?? m.from?.emailAddress ?? '', fromDisplay: m.fromHeader?.displayableName ?? m.from?.displayableName ?? '', to: m.to?.emailAddress ?? '', toDisplay: m.to?.displayableName ?? '', dateReceived: m.dateReceived ?? '', reason: m.reason ?? '', reasonCode: m.reasonCode ?? '', policyInfo: m.policyInfo ?? '', route: m.route ?? '', hasAttachments: m.hasAttachments ?? false, size: m.size ?? 0, }); } cursor = pagination.next ?? null; } while (cursor && all.length < maxMessages); return { messages: all, totalCount }; } // ── Account ──────────────────────────────────────────────────────────────── /** * POST /api/account/get-account */ async testConnection(): Promise<{ ok: boolean; accountName?: string; packageName?: string; error?: string }> { try { const data = await this.request('POST', '/api/account/get-account', { data: [{ accountCode: this.accountCode }] }); 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', accountCode: process.env.MIMECAST_ACCOUNT_CODE ?? '', }); } return _client; } export function getMimecastClientForTenant(tenant: { client_id: string; client_secret: string; base_url?: string; account_code?: string; }): MimecastClient { return new MimecastClient({ clientId: tenant.client_id, clientSecret: tenant.client_secret, baseUrl: tenant.base_url ?? 'https://api.services.mimecast.com', accountCode: tenant.account_code ?? '', }); }