feat: Mimecast multi-tenant held mail viewer
- migration 062: mimecast_tenants table (company_id, client_id/secret, account_code) - Seed Wulf (CUSA13A95) + Seubert (CUSA96A181) tenants - MimecastClient.getHeldMessages(): full pagination via meta.pagination.next cursor (API always returns 10/page regardless of pageSize param, totalCount in meta) - getMimecastClientForTenant() factory for per-tenant instantiation - GET /api/mimecast/held?tenantId=&recipient= — fetches all tenants in parallel, merges + sorts by date, returns per-tenant counts + combined messages[] - Held Mail tab on /admin/sync/mimecast (on-demand load, recipient filter, tenant badges, policy filter dropdown, DMARC/impersonation highlighted red)
This commit is contained in:
parent
a98c0daf15
commit
fcdec8e38b
12 changed files with 2208 additions and 27 deletions
22
app/api/mimecast/cloud-user/route.ts
Normal file
22
app/api/mimecast/cloud-user/route.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getMimecastClient } from '@/lib/services/mimecast-client';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const emailAddress = req.nextUrl.searchParams.get('emailAddress');
|
||||
const domain = req.nextUrl.searchParams.get('domain');
|
||||
|
||||
if (!emailAddress || !domain) {
|
||||
return NextResponse.json({ error: 'emailAddress and domain are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const client = getMimecastClient();
|
||||
const user = await client.getCloudUser(emailAddress, domain);
|
||||
if (!user) {
|
||||
return NextResponse.json({ found: false });
|
||||
}
|
||||
return NextResponse.json({ found: true, user });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
91
app/api/mimecast/held/route.ts
Normal file
91
app/api/mimecast/held/route.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { postgresClient } from '@/lib/services/postgres-client';
|
||||
import { getMimecastClientForTenant } from '@/lib/services/mimecast-client';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const tenantId = searchParams.get('tenantId');
|
||||
const recipient = searchParams.get('recipient') ?? undefined;
|
||||
|
||||
// Build query — filter by tenant if provided, else all enabled tenants
|
||||
let tenants: any[];
|
||||
if (tenantId) {
|
||||
const r = await postgresClient.query(
|
||||
`SELECT mt.*, c.company_name FROM mimecast_tenants mt
|
||||
LEFT JOIN companies c ON c.id = mt.company_id
|
||||
WHERE mt.id = $1 AND mt.enabled = true`,
|
||||
[tenantId]
|
||||
);
|
||||
tenants = r.rows;
|
||||
} else {
|
||||
const r = await postgresClient.query(
|
||||
`SELECT mt.*, c.company_name FROM mimecast_tenants mt
|
||||
LEFT JOIN companies c ON c.id = mt.company_id
|
||||
WHERE mt.enabled = true
|
||||
ORDER BY mt.account_name`
|
||||
);
|
||||
tenants = r.rows;
|
||||
}
|
||||
|
||||
if (!tenants.length) {
|
||||
return NextResponse.json({ error: 'No configured Mimecast tenants found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Fetch held messages for each tenant in parallel
|
||||
const results = await Promise.allSettled(
|
||||
tenants.map(async (tenant) => {
|
||||
const client = getMimecastClientForTenant(tenant);
|
||||
const { messages, totalCount } = await client.getHeldMessages({ recipient, maxMessages: 500 });
|
||||
return {
|
||||
tenantId: tenant.id,
|
||||
accountCode: tenant.account_code,
|
||||
accountName: tenant.account_name,
|
||||
companyName: tenant.company_name ?? tenant.account_name,
|
||||
companyId: tenant.company_id,
|
||||
messages,
|
||||
totalCount,
|
||||
error: null,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const tenantResults = results.map((r, i) => {
|
||||
if (r.status === 'fulfilled') return r.value;
|
||||
return {
|
||||
tenantId: tenants[i].id,
|
||||
accountCode: tenants[i].account_code,
|
||||
accountName: tenants[i].account_name,
|
||||
companyName: tenants[i].company_name ?? tenants[i].account_name,
|
||||
companyId: tenants[i].company_id,
|
||||
messages: [],
|
||||
totalCount: 0,
|
||||
error: r.reason?.message ?? 'Failed to fetch',
|
||||
};
|
||||
});
|
||||
|
||||
const allMessages = tenantResults.flatMap(t =>
|
||||
t.messages.map((m: any) => ({ ...m, tenantId: t.tenantId, accountName: t.accountName, companyName: t.companyName }))
|
||||
);
|
||||
|
||||
// Sort all messages newest first
|
||||
allMessages.sort((a, b) => new Date(b.dateReceived).getTime() - new Date(a.dateReceived).getTime());
|
||||
|
||||
return NextResponse.json({
|
||||
tenants: tenantResults.map(t => ({
|
||||
tenantId: t.tenantId,
|
||||
accountName: t.accountName,
|
||||
companyName: t.companyName,
|
||||
companyId: t.companyId,
|
||||
count: t.messages.length,
|
||||
totalCount: t.totalCount,
|
||||
error: t.error,
|
||||
})),
|
||||
messages: allMessages,
|
||||
totalCount: allMessages.length,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('[Mimecast] held messages error:', error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -6,18 +6,20 @@ export async function POST(request: NextRequest) {
|
|||
try {
|
||||
console.log('Starting classification icons sync...');
|
||||
|
||||
// Fetch classification icons from Autotask API
|
||||
// Fetch classification picklist from Companies field info
|
||||
const autotaskClient = getAutotaskClient();
|
||||
const classifications = await autotaskClient.getClassificationIcons();
|
||||
|
||||
if (!classifications || classifications.length === 0) {
|
||||
const fields = await autotaskClient.getFieldInfo('Companies');
|
||||
const classificationField = fields.find((f: any) => f.name === 'classification');
|
||||
|
||||
if (!classificationField || !classificationField.picklistValues?.length) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No classifications found in Autotask' },
|
||||
{ error: 'No classification picklist found in Companies field info' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`Fetched ${classifications.length} classification icons from Autotask`);
|
||||
const classifications = classificationField.picklistValues;
|
||||
console.log(`Fetched ${classifications.length} classification values from Autotask`);
|
||||
|
||||
let inserted = 0;
|
||||
let updated = 0;
|
||||
|
|
@ -26,16 +28,16 @@ export async function POST(request: NextRequest) {
|
|||
for (const classification of classifications) {
|
||||
const result = await postgresClient.query(
|
||||
`INSERT INTO company_classifications (
|
||||
classification_id,
|
||||
name,
|
||||
description,
|
||||
classification_id,
|
||||
name,
|
||||
description,
|
||||
is_active,
|
||||
is_system,
|
||||
updated_at,
|
||||
synced_at
|
||||
) VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (classification_id)
|
||||
DO UPDATE SET
|
||||
ON CONFLICT (classification_id)
|
||||
DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
description = EXCLUDED.description,
|
||||
is_active = EXCLUDED.is_active,
|
||||
|
|
@ -44,9 +46,9 @@ export async function POST(request: NextRequest) {
|
|||
synced_at = CURRENT_TIMESTAMP
|
||||
RETURNING (xmax = 0) AS inserted`,
|
||||
[
|
||||
classification.id,
|
||||
classification.name,
|
||||
classification.description || null,
|
||||
parseInt(String(classification.value)),
|
||||
classification.label,
|
||||
null,
|
||||
classification.isActive !== false,
|
||||
classification.isSystem || false
|
||||
]
|
||||
|
|
@ -66,7 +68,7 @@ export async function POST(request: NextRequest) {
|
|||
total: classifications.length,
|
||||
inserted,
|
||||
updated,
|
||||
message: `Synced ${classifications.length} classification icons`
|
||||
message: `Synced ${classifications.length} classifications from Companies field info`
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue