412 lines
13 KiB
TypeScript
412 lines
13 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|