chore: check in pending work — queue preferences, QBO AR diagnostics, mobile engagement fixes, ops scripts
Bundles several in-progress efforts that were sitting uncommitted: - User queue-preferences (migration 087, API route, popover component) - QBO invoice soft-delete (migration 088) and AR diagnostics route - Dashboard/mobile engagement route and page adjustments - Docker Compose log-rotation config - One-off ticket/RMM investigation scripts (scripts/) - Planning docs: phase verification/pattern notes, mobile shell design spec - .gitignore: exclude local scratch financial/inventory data and Claude Code worktree/local-settings runtime state (never meant for version control) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6RuWdiUiXrPK6FLBHjtpY
This commit is contained in:
parent
b638189cb0
commit
672f17b7f9
35 changed files with 2801 additions and 92 deletions
12
scripts/check-699456-parse.ts
Normal file
12
scripts/check-699456-parse.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
config({ path: resolve('/opt/stacks/pulse/.env.local') });
|
||||
|
||||
import { parseAndStoreMessage } from '../lib/services/phishing-eml-service';
|
||||
|
||||
async function main() {
|
||||
const result = await parseAndStoreMessage({ reportId: '6bdd5c3f-0844-4673-941b-d5b438739383', ticketId: 699456 });
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
main().catch((err) => { console.error(err); process.exit(1); });
|
||||
31
scripts/check-suspect-tickets.ts
Normal file
31
scripts/check-suspect-tickets.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
config({ path: resolve(__dirname, '../.env.local') });
|
||||
|
||||
const SUSPECTS = [573672, 568742, 541321, 532302];
|
||||
|
||||
const headers = {
|
||||
Username: process.env.AUTOTASK_USERNAME!,
|
||||
Secret: process.env.AUTOTASK_SECRET!,
|
||||
APIIntegrationcode: process.env.AUTOTASK_API_INTEGRATION_CODE!,
|
||||
Accept: 'application/json',
|
||||
};
|
||||
|
||||
async function main() {
|
||||
for (const id of SUSPECTS) {
|
||||
const res = await fetch(`${process.env.AUTOTASK_API_URL}/Tickets/${id}`, { headers });
|
||||
const data = await res.json();
|
||||
const t = data.item;
|
||||
if (!t) {
|
||||
console.log(`Ticket ${id}: NOT FOUND IN AUTOTASK`);
|
||||
continue;
|
||||
}
|
||||
console.log(`Ticket ${id} (${t.ticketNumber}):`);
|
||||
for (const f of ['title','purchaseOrderNumber','ticketNumber','changeInfoField1','changeInfoField2','changeInfoField3','changeInfoField4','changeInfoField5']) {
|
||||
const v = t[f];
|
||||
const len = typeof v === 'string' ? v.length : 0;
|
||||
if (len > 0) console.log(` ${f.padEnd(24)} len=${len}${len > (f === 'purchaseOrderNumber' || f === 'ticketNumber' ? 100 : 255) ? ' << OVERFLOW' : ''}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
main().catch(console.error);
|
||||
192
scripts/diagnose-ticket-varchar-overflow.ts
Normal file
192
scripts/diagnose-ticket-varchar-overflow.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
/**
|
||||
* diagnose-ticket-varchar-overflow.ts
|
||||
*
|
||||
* One-shot: identify Autotask Tickets whose string fields exceed the
|
||||
* varchar() limits declared on the local postgres `tickets` table.
|
||||
*
|
||||
* Why: every full tickets sync since 2026-05-21 16:14 fails with
|
||||
* `value too long for type character varying(255)`
|
||||
* during bulkUpsert. The pg error doesn't name the column, so we replicate
|
||||
* the real sync's Autotask query EXACTLY (createDate filter, no IncludeFields)
|
||||
* and walk the response checking every string field on every record.
|
||||
*
|
||||
* Stops after first 10 violations found.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/diagnose-ticket-varchar-overflow.ts
|
||||
*/
|
||||
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
|
||||
config({ path: resolve(__dirname, '../.env.local') });
|
||||
|
||||
const API_BASE = process.env.AUTOTASK_API_URL!;
|
||||
const USERNAME = process.env.AUTOTASK_USERNAME!;
|
||||
const SECRET = process.env.AUTOTASK_SECRET!;
|
||||
const INT_CODE = process.env.AUTOTASK_API_INTEGRATION_CODE!;
|
||||
|
||||
if (!API_BASE || !USERNAME || !SECRET || !INT_CODE) {
|
||||
console.error('Missing Autotask credentials in env (.env.local)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Postgres tickets table varchar column limits (keep in sync with schema).
|
||||
// Autotask camelCase field name -> postgres column limit.
|
||||
const FIELD_LIMITS: Record<string, number> = {
|
||||
title: 255,
|
||||
purchaseOrderNumber: 100,
|
||||
ticketNumber: 100,
|
||||
changeInfoField1: 255,
|
||||
changeInfoField2: 255,
|
||||
changeInfoField3: 255,
|
||||
changeInfoField4: 255,
|
||||
changeInfoField5: 255,
|
||||
};
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
return {
|
||||
Username: USERNAME,
|
||||
Secret: SECRET,
|
||||
APIIntegrationcode: INT_CODE,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
interface Violation {
|
||||
ticketId: number;
|
||||
ticketNumber?: string;
|
||||
field: string;
|
||||
length: number;
|
||||
limit: number;
|
||||
preview: string;
|
||||
createDate?: string;
|
||||
}
|
||||
|
||||
const MAX_VIOLATIONS = 10;
|
||||
|
||||
async function main() {
|
||||
const since = new Date();
|
||||
since.setUTCFullYear(since.getUTCFullYear() - 2);
|
||||
const sinceIso = since.toISOString();
|
||||
|
||||
console.log(`Scanning Tickets with createDate >= ${sinceIso} (matches real sync filter exactly)`);
|
||||
console.log(`Field limits: ${JSON.stringify(FIELD_LIMITS)}`);
|
||||
console.log(`Will also report ANY string field >255 chars even if not in known limit list.`);
|
||||
console.log('');
|
||||
|
||||
// Real sync sends ONLY {MaxRecords, filter} — no IncludeFields. Match exactly.
|
||||
const baseBody = {
|
||||
MaxRecords: 500,
|
||||
filter: [{ field: 'createDate', op: 'gte', value: sinceIso }],
|
||||
};
|
||||
|
||||
const violations: Violation[] = [];
|
||||
const unknownFieldOverflows: Violation[] = [];
|
||||
let totalScanned = 0;
|
||||
let pageIdx = 0;
|
||||
|
||||
let url: string | null = `${API_BASE}/Tickets/query`;
|
||||
while (url) {
|
||||
pageIdx += 1;
|
||||
const res: Response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify(baseBody),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const t = await res.text();
|
||||
throw new Error(`Autotask query failed (page ${pageIdx}): ${res.status} ${t.slice(0, 500)}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
const items: any[] = data.items || [];
|
||||
totalScanned += items.length;
|
||||
|
||||
for (const rec of items) {
|
||||
for (const [field, v] of Object.entries(rec)) {
|
||||
if (typeof v !== 'string') continue;
|
||||
const limit = FIELD_LIMITS[field];
|
||||
if (limit !== undefined && v.length > limit) {
|
||||
violations.push({
|
||||
ticketId: rec.id,
|
||||
ticketNumber: rec.ticketNumber,
|
||||
field,
|
||||
length: v.length,
|
||||
limit,
|
||||
preview: v.slice(0, 100) + (v.length > 100 ? '…' : ''),
|
||||
createDate: rec.createDate,
|
||||
});
|
||||
} else if (limit === undefined && v.length > 255) {
|
||||
// Any other string field >255 (could explain why we missed it earlier).
|
||||
unknownFieldOverflows.push({
|
||||
ticketId: rec.id,
|
||||
ticketNumber: rec.ticketNumber,
|
||||
field,
|
||||
length: v.length,
|
||||
limit: -1,
|
||||
preview: v.slice(0, 100) + '…',
|
||||
createDate: rec.createDate,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
` page ${pageIdx}: scanned ${items.length} (total ${totalScanned}, hits known=${violations.length} other>255=${unknownFieldOverflows.length})\n`
|
||||
);
|
||||
|
||||
if (violations.length >= MAX_VIOLATIONS) {
|
||||
console.log(`\nReached ${MAX_VIOLATIONS} known-field violations, stopping early.`);
|
||||
break;
|
||||
}
|
||||
|
||||
url = data.pageDetails?.nextPageUrl || null;
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(`Scan ended. Pages: ${pageIdx}. Tickets scanned: ${totalScanned}.`);
|
||||
console.log(`Violations on KNOWN columns: ${violations.length}`);
|
||||
console.log(`Other string fields >255 chars: ${unknownFieldOverflows.length}`);
|
||||
console.log('');
|
||||
|
||||
if (violations.length === 0 && unknownFieldOverflows.length === 0) {
|
||||
console.log('NO violations found. The offending record may have been modified since the failed sync.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
const byField: Record<string, { count: number; maxLen: number }> = {};
|
||||
for (const v of violations) {
|
||||
const b = byField[v.field] || { count: 0, maxLen: 0 };
|
||||
b.count += 1;
|
||||
b.maxLen = Math.max(b.maxLen, v.length);
|
||||
byField[v.field] = b;
|
||||
}
|
||||
console.log('Known-column violations by field:');
|
||||
for (const [field, { count, maxLen }] of Object.entries(byField)) {
|
||||
console.log(` ${field.padEnd(24)} count=${count} maxLen=${maxLen} limit=${FIELD_LIMITS[field]}`);
|
||||
}
|
||||
console.log('');
|
||||
console.log('First 10 violations:');
|
||||
for (const v of violations.slice(0, 10)) {
|
||||
console.log(
|
||||
` ticket ${v.ticketId} (${v.ticketNumber ?? '?'}) field=${v.field} length=${v.length} createDate=${v.createDate}`
|
||||
);
|
||||
console.log(` preview: ${v.preview.replace(/\s+/g, ' ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (unknownFieldOverflows.length > 0) {
|
||||
console.log('');
|
||||
console.log('Other (unmapped) string fields >255 chars — these would NOT cause the postgres error but worth noting:');
|
||||
const byOther: Record<string, number> = {};
|
||||
for (const v of unknownFieldOverflows) byOther[v.field] = (byOther[v.field] || 0) + 1;
|
||||
for (const [f, c] of Object.entries(byOther)) console.log(` ${f.padEnd(28)} count=${c}`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('FATAL:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
22
scripts/list-rmm-sites.ts
Normal file
22
scripts/list-rmm-sites.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
config({ path: resolve('/opt/stacks/pulse/.env.local') });
|
||||
|
||||
import { getDattoRMMClient } from '../lib/services/datto-rmm-factory';
|
||||
|
||||
async function main() {
|
||||
const client = getDattoRMMClient();
|
||||
const sites = await client.getSites();
|
||||
|
||||
console.log(`Found ${sites.length} sites\n`);
|
||||
console.log('NAME\tUID\tDEVICES\tON-DEMAND');
|
||||
for (const s of sites.sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
const devs = s.devicesStatus?.numberOfDevices ?? '';
|
||||
console.log(`${s.name}\t${s.uid}\t${devs}\t${s.onDemand}`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
26
scripts/peek-ticket.ts
Normal file
26
scripts/peek-ticket.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
config({ path: resolve('/opt/stacks/pulse/.env.local') });
|
||||
|
||||
async function main() {
|
||||
const res = await fetch(`${process.env.AUTOTASK_API_URL}/Tickets/query`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Username: process.env.AUTOTASK_USERNAME!,
|
||||
Secret: process.env.AUTOTASK_SECRET!,
|
||||
APIIntegrationcode: process.env.AUTOTASK_API_INTEGRATION_CODE!,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ MaxRecords: 1, filter: [{ field: 'id', op: 'gt', value: 0 }] }),
|
||||
});
|
||||
const data = await res.json();
|
||||
const rec = data.items[0];
|
||||
console.log('All string fields:');
|
||||
for (const [k, v] of Object.entries(rec).sort()) {
|
||||
if (typeof v === 'string') console.log(` ${k.padEnd(45)} len=${(v as string).length}`);
|
||||
}
|
||||
console.log('\nAll field names (for reference):');
|
||||
console.log(Object.keys(rec).sort().join(', '));
|
||||
}
|
||||
main().catch(console.error);
|
||||
105
scripts/reanalyze-seubert-burst.ts
Normal file
105
scripts/reanalyze-seubert-burst.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
/**
|
||||
* reanalyze-seubert-burst.ts
|
||||
*
|
||||
* One-shot: (1) reclassify ticket 699415's campaign now that the container
|
||||
* has been rebuilt with the Phase 23 USER_AWARENESS classifier (the existing
|
||||
* classification ran against the pre-Phase-23 code and is stale), and
|
||||
* (2) run /analyze's parse+group steps for the 7 Seubert reports (699419,
|
||||
* 699421, 699422, 699433, 699435, 699441, 699445) that landed before the
|
||||
* company's automation gate was switched on and never got EML-parsed.
|
||||
*
|
||||
* Usage:
|
||||
* POSTGRES_HOST=localhost npx tsx scripts/reanalyze-seubert-burst.ts
|
||||
*/
|
||||
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
config({ path: resolve('/opt/stacks/pulse/.env.local') });
|
||||
|
||||
import postgresClient from '../lib/services/postgres-client';
|
||||
import { detectPhishingTicket, type DetectableTicket } from '../lib/services/phishing-detector';
|
||||
import { parseAndStoreMessage } from '../lib/services/phishing-eml-service';
|
||||
import { groupReportIntoCampaign } from '../lib/services/campaign-grouping-service';
|
||||
import { classifyCampaign } from '../lib/services/campaign-classifier';
|
||||
|
||||
const RECLASSIFY_CAMPAIGN_ID = '60a3613c-5b6c-45b1-8441-411a3fbf100c'; // ticket 699415
|
||||
const UNPARSED_TICKET_IDS = [699419, 699421, 699422, 699433, 699435, 699441, 699445];
|
||||
|
||||
async function reanalyzeTicket(ticketId: number) {
|
||||
const row = await postgresClient.query<{
|
||||
id: string;
|
||||
ticket_number: string | null;
|
||||
title: string | null;
|
||||
description: string | null;
|
||||
company_id: number | null;
|
||||
contact_id: number | null;
|
||||
created_by_contact_id: number | null;
|
||||
}>(
|
||||
`SELECT id, ticket_number, title, description, company_id, contact_id, created_by_contact_id
|
||||
FROM tickets WHERE id = $1`,
|
||||
[ticketId]
|
||||
);
|
||||
const r = row.rows[0];
|
||||
if (!r) {
|
||||
console.log(` ticket ${ticketId}: NOT FOUND in Postgres`);
|
||||
return;
|
||||
}
|
||||
|
||||
const ticket: DetectableTicket = {
|
||||
id: Number(r.id),
|
||||
ticket_number: r.ticket_number,
|
||||
title: r.title,
|
||||
description: r.description,
|
||||
company_id: r.company_id,
|
||||
contact_id: r.contact_id,
|
||||
created_by_contact_id: r.created_by_contact_id,
|
||||
};
|
||||
|
||||
const detection = await detectPhishingTicket(ticket);
|
||||
if (!detection.flagged || !detection.reportId) {
|
||||
console.log(` ticket ${ticketId}: detector did not flag it (unexpected — it already has a reports row)`);
|
||||
return;
|
||||
}
|
||||
|
||||
const parseResult = await parseAndStoreMessage({ reportId: detection.reportId, ticketId });
|
||||
const grouped = await groupReportIntoCampaign(detection.reportId);
|
||||
|
||||
console.log(
|
||||
` ticket ${ticketId}: parse=${JSON.stringify(parseResult)} campaignId=${grouped?.campaignId ?? 'null'} groupMethod=${grouped?.groupMethod ?? 'null'}`
|
||||
);
|
||||
|
||||
if (grouped?.campaignId) {
|
||||
try {
|
||||
const result = await classifyCampaign(grouped.campaignId);
|
||||
console.log(` classified: verdict=${result.verdict}`);
|
||||
} catch (err) {
|
||||
console.log(` classify failed: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('=== Step 1: Reclassify 699415 campaign (stale pre-Phase-23 verdict) ===');
|
||||
const before = await postgresClient.query<{ verdict: string | null }>(
|
||||
`SELECT verdict FROM classifications WHERE campaign_id = $1 ORDER BY created_at DESC LIMIT 1`,
|
||||
[RECLASSIFY_CAMPAIGN_ID]
|
||||
);
|
||||
console.log(` verdict before: ${before.rows[0]?.verdict ?? 'none'}`);
|
||||
|
||||
const result = await classifyCampaign(RECLASSIFY_CAMPAIGN_ID);
|
||||
console.log(` verdict after: ${result.verdict}`);
|
||||
console.log(` reasons: ${JSON.stringify(result.reasons ?? [], null, 2)}`);
|
||||
|
||||
console.log('\n=== Step 2: Analyze the 7 unparsed Seubert reports ===');
|
||||
for (const ticketId of UNPARSED_TICKET_IDS) {
|
||||
await reanalyzeTicket(ticketId);
|
||||
}
|
||||
|
||||
console.log('\nDone.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
12
scripts/reclassify-699456.ts
Normal file
12
scripts/reclassify-699456.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
config({ path: resolve('/opt/stacks/pulse/.env.local') });
|
||||
|
||||
import { classifyCampaign } from '../lib/services/campaign-classifier';
|
||||
|
||||
async function main() {
|
||||
const result = await classifyCampaign('5cdb31a3-64c9-4237-8edc-7e317f4f614c');
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
main().catch((err) => { console.error(err); process.exit(1); });
|
||||
95
scripts/verify-blast-radius-held-bug.ts
Normal file
95
scripts/verify-blast-radius-held-bug.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
/**
|
||||
* verify-blast-radius-held-bug.ts
|
||||
*
|
||||
* Ticket 699687 (Tomlinson, Seubert) shows Blast Radius: Matched 16,
|
||||
* Delivered 1, Held 15, with btomlinson@seubert.com's per-recipient status
|
||||
* shown as "held" — despite this being the accidental Guardian Protection
|
||||
* report we already confirmed was cleanly delivered (spamScore 0, no
|
||||
* detection) via Mimecast's own trace. Hypothesis: getHeldMessages() in
|
||||
* lib/services/mimecast-blast-radius.ts is scoped ONLY by recipient (Mimecast's
|
||||
* get-hold-message-list API has no sender/subject/date filter), so it pulls
|
||||
* in EVERY message currently sitting in btomlinson's hold queue — unrelated
|
||||
* to Guardian Protection — and the per-recipient merge logic lets any held
|
||||
* row silently overwrite a same-recipient delivered row. This script
|
||||
* independently verifies by calling the same two Mimecast calls the blast
|
||||
* radius module makes and inspecting what's actually in the held queue.
|
||||
*
|
||||
* Usage:
|
||||
* POSTGRES_HOST=localhost npx tsx scripts/verify-blast-radius-held-bug.ts
|
||||
*/
|
||||
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
config({ path: resolve('/opt/stacks/pulse/.env.local') });
|
||||
|
||||
import postgresClient from '../lib/services/postgres-client';
|
||||
import { getMimecastClientForTenant } from '../lib/services/mimecast-client';
|
||||
|
||||
const SEUBERT_COMPANY_ID = 29683407;
|
||||
const SENDER = 'guardian_protection_do_not_reply@guardianprotection.com';
|
||||
const RECIPIENT = 'btomlinson@seubert.com';
|
||||
const SUBJECT = "Unexpected Activity for Tomlinson’s Home: The Front Door was left unlocked at 1:39 pm";
|
||||
|
||||
async function main() {
|
||||
const tenantRow = await postgresClient.query<{
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
base_url: string | null;
|
||||
account_name: string | null;
|
||||
}>(
|
||||
`SELECT client_id, client_secret, base_url, account_name FROM mimecast_tenants WHERE company_id = $1 AND enabled = true`,
|
||||
[SEUBERT_COMPANY_ID]
|
||||
);
|
||||
const tenant = tenantRow.rows[0];
|
||||
if (!tenant) {
|
||||
console.log('No enabled Mimecast tenant for Seubert — cannot verify.');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`Using Mimecast tenant: ${tenant.account_name}`);
|
||||
|
||||
const client = getMimecastClientForTenant({
|
||||
client_id: tenant.client_id,
|
||||
client_secret: tenant.client_secret,
|
||||
base_url: tenant.base_url ?? undefined,
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const start = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
const startStr = start.toISOString().replace(/\.\d{3}Z$/, '+0000');
|
||||
const endStr = now.toISOString().replace(/\.\d{3}Z$/, '+0000');
|
||||
|
||||
console.log(`\n=== 1. searchDeliveredMessages (scoped: sender+recipient+subject+24h window) ===`);
|
||||
const delivered = await client.searchDeliveredMessages({
|
||||
from: SENDER,
|
||||
to: RECIPIENT,
|
||||
subject: SUBJECT,
|
||||
start: startStr,
|
||||
end: endStr,
|
||||
});
|
||||
console.log(`Delivered rows found: ${delivered.messages?.length ?? 0}`);
|
||||
console.log(JSON.stringify(delivered.messages, null, 2));
|
||||
|
||||
console.log(`\n=== 2. getHeldMessages (scoped ONLY by recipient — no sender/subject/date filter possible) ===`);
|
||||
const held = await client.getHeldMessages({ recipient: RECIPIENT });
|
||||
console.log(`Held rows found: ${held.messages.length} (totalCount reported: ${held.totalCount})`);
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
held.messages.map((m) => ({ from: m.from, subject: m.subject, dateReceived: m.dateReceived, reason: m.reason })),
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
const heldFromGuardianProtection = held.messages.filter((m) =>
|
||||
(m.from ?? '').toLowerCase().includes('guardianprotection.com')
|
||||
);
|
||||
console.log(`\n=== 3. Of those held messages, how many are actually from Guardian Protection? ===`);
|
||||
console.log(`${heldFromGuardianProtection.length} of ${held.messages.length}`);
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
94
scripts/verify-guardian-protection-mimecast.ts
Normal file
94
scripts/verify-guardian-protection-mimecast.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
/**
|
||||
* verify-guardian-protection-mimecast.ts
|
||||
*
|
||||
* Ticket 699687 (Tomlinson, Seubert) — an accidental phishing report of a
|
||||
* genuine Guardian Protection (alarm.com-platform) home-security notification
|
||||
* ("The Front Door was left unlocked"). The forwarded/reported copy's
|
||||
* primary Authentication-Results shows spf=fail/dkim=fail/dmarc=fail (a
|
||||
* known forwarding artifact — see authResultsOriginal), while the pre-
|
||||
* forwarding authResultsOriginal shows all-pass. Before concluding Guardian
|
||||
* Protection has bad email hygiene, independently verify against Mimecast's
|
||||
* own real-time record of this exact message at Seubert's tenant.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/verify-guardian-protection-mimecast.ts
|
||||
*/
|
||||
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
config({ path: resolve('/opt/stacks/pulse/.env.local') });
|
||||
|
||||
import postgresClient from '../lib/services/postgres-client';
|
||||
import { getMimecastClientForTenant } from '../lib/services/mimecast-client';
|
||||
|
||||
const SEUBERT_COMPANY_ID = 29683407;
|
||||
const SENDER = 'guardian_protection_do_not_reply@guardianprotection.com';
|
||||
const RECIPIENT = 'btomlinson@seubert.com';
|
||||
const MESSAGE_ID = '<d12e6a25451a7f6dddac51480c302bef@guardianprotection.com>';
|
||||
|
||||
async function main() {
|
||||
const tenantRow = await postgresClient.query<{
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
base_url: string | null;
|
||||
account_name: string | null;
|
||||
}>(
|
||||
`SELECT client_id, client_secret, base_url, account_name FROM mimecast_tenants WHERE company_id = $1 AND enabled = true`,
|
||||
[SEUBERT_COMPANY_ID]
|
||||
);
|
||||
const tenant = tenantRow.rows[0];
|
||||
if (!tenant) {
|
||||
console.log('No enabled Mimecast tenant for Seubert — cannot verify.');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`Using Mimecast tenant: ${tenant.account_name}`);
|
||||
|
||||
const client = getMimecastClientForTenant({
|
||||
client_id: tenant.client_id,
|
||||
client_secret: tenant.client_secret,
|
||||
base_url: tenant.base_url ?? undefined,
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const start = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000);
|
||||
const startStr = start.toISOString().replace(/\.\d{3}Z$/, '+0000');
|
||||
const endStr = now.toISOString().replace(/\.\d{3}Z$/, '+0000');
|
||||
|
||||
console.log(`\n=== 1. Exact sender+recipient match, Mimecast's own trace ===`);
|
||||
const exact = await client.searchDeliveredMessages({
|
||||
from: SENDER,
|
||||
to: RECIPIENT,
|
||||
start: startStr,
|
||||
end: endStr,
|
||||
});
|
||||
console.log(JSON.stringify(exact, null, 2));
|
||||
|
||||
console.log(`\n=== 2. getMessageInfo by Message-ID ===`);
|
||||
try {
|
||||
const info = await client.getMessageInfo(MESSAGE_ID);
|
||||
console.log(JSON.stringify(info, null, 2));
|
||||
} catch (err) {
|
||||
console.log('getMessageInfo failed:', err instanceof Error ? err.message : err);
|
||||
}
|
||||
|
||||
console.log(`\n=== 3. Held messages for Tomlinson ===`);
|
||||
const held = await client.getHeldMessages({ recipient: RECIPIENT });
|
||||
console.log(JSON.stringify(held, null, 2));
|
||||
|
||||
console.log(`\n=== 4. Recent threat events mentioning guardianprotection.com or alarm.com ===`);
|
||||
const threats = await client.getThreatEvents({ pageSize: 500 });
|
||||
const relevant = threats.items.filter((t) =>
|
||||
(t.actorEmail ?? '').toLowerCase().includes('guardianprotection.com') ||
|
||||
(t.actorEmail ?? '').toLowerCase().includes('alarm.com')
|
||||
);
|
||||
console.log(`Total threat events fetched: ${threats.items.length}`);
|
||||
console.log(`Events involving guardianprotection.com/alarm.com: ${relevant.length}`);
|
||||
console.log(JSON.stringify(relevant, null, 2));
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
56
scripts/verify-held-date-scope.ts
Normal file
56
scripts/verify-held-date-scope.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
config({ path: resolve('/opt/stacks/pulse/.env.local') });
|
||||
|
||||
import postgresClient from '../lib/services/postgres-client';
|
||||
import { getMimecastClientForTenant } from '../lib/services/mimecast-client';
|
||||
|
||||
const SEUBERT_COMPANY_ID = 29683407;
|
||||
const RECIPIENT = 'btomlinson@seubert.com';
|
||||
|
||||
async function main() {
|
||||
const tenantRow = await postgresClient.query<any>(
|
||||
`SELECT client_id, client_secret, base_url, account_name FROM mimecast_tenants WHERE company_id = $1 AND enabled = true`,
|
||||
[SEUBERT_COMPANY_ID]
|
||||
);
|
||||
const tenant = tenantRow.rows[0];
|
||||
const client: any = getMimecastClientForTenant({
|
||||
client_id: tenant.client_id,
|
||||
client_secret: tenant.client_secret,
|
||||
base_url: tenant.base_url ?? undefined,
|
||||
});
|
||||
|
||||
// Exact production window: createdAt (report creation, 2026-07-17T18:10:55Z) +/- 24h, end clamped to now.
|
||||
const createdAt = new Date('2026-07-17T18:10:55.844Z');
|
||||
const start = new Date(createdAt.getTime() - 24 * 60 * 60 * 1000);
|
||||
const end = new Date(Math.min(createdAt.getTime() + 24 * 60 * 60 * 1000, Date.now()));
|
||||
const startStr = start.toISOString().replace(/\.\d{3}Z$/, '+0000');
|
||||
const endStr = end.toISOString().replace(/\.\d{3}Z$/, '+0000');
|
||||
|
||||
console.log(`Querying held messages for ${RECIPIENT} scoped to the EXACT production window: ${startStr}..${endStr}`);
|
||||
|
||||
const body = {
|
||||
data: [
|
||||
{
|
||||
admin: true,
|
||||
start: startStr,
|
||||
end: endStr,
|
||||
searchBy: { fieldName: 'recipient', value: RECIPIENT },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = await client.request('POST', '/api/gateway/get-hold-message-list', body);
|
||||
const msgs = result?.data ?? [];
|
||||
console.log(`Rows returned: ${msgs.length}`);
|
||||
console.log(JSON.stringify(msgs.map((m: any) => ({
|
||||
from: m.fromHeader?.emailAddress ?? m.from?.emailAddress,
|
||||
subject: m.subject,
|
||||
dateReceived: m.dateReceived,
|
||||
reason: m.reason,
|
||||
})), null, 2));
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => { console.error(err); process.exit(1); });
|
||||
109
scripts/verify-mansfield-mimecast.ts
Normal file
109
scripts/verify-mansfield-mimecast.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
/**
|
||||
* verify-mansfield-mimecast.ts
|
||||
*
|
||||
* Ticket 699451 (Richard Mansfield, Seubert) was manually forwarded by a
|
||||
* technician, not reported via KnowBe4's Phish Alert button or Microsoft's
|
||||
* "Report Message" add-in — so the detector never created a `reports` row,
|
||||
* and everything we know about the sender ("eserver@it-support.care") comes
|
||||
* from a human's free-text ticket description, not a parsed EML/header.
|
||||
*
|
||||
* it-support.care IS on the KNOWN_SIMULATION_SENDERS allowlist
|
||||
* (lib/services/campaign-classifier.ts) for KnowBe4. Before trusting the
|
||||
* ticket text at face value, independently verify against Mimecast's actual
|
||||
* message-trace data for Seubert's tenant: does a message from that sender
|
||||
* to Rich, with that subject, in that time window, actually exist and what
|
||||
* does Mimecast say about it (delivered/held/rejected, real headers)?
|
||||
*
|
||||
* Usage:
|
||||
* POSTGRES_HOST=localhost npx tsx scripts/verify-mansfield-mimecast.ts
|
||||
*/
|
||||
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
config({ path: resolve('/opt/stacks/pulse/.env.local') });
|
||||
|
||||
import postgresClient from '../lib/services/postgres-client';
|
||||
import { getMimecastClientForTenant } from '../lib/services/mimecast-client';
|
||||
|
||||
const SEUBERT_COMPANY_ID = 29683407;
|
||||
const REPORTED_SENDER = 'eserver@it-support.care';
|
||||
const RECIPIENT = 'rmansfield@seubert.com';
|
||||
const SUBJECT = 'Internal Email Problems';
|
||||
|
||||
async function main() {
|
||||
const tenantRow = await postgresClient.query<{
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
base_url: string | null;
|
||||
account_name: string | null;
|
||||
}>(
|
||||
`SELECT client_id, client_secret, base_url, account_name FROM mimecast_tenants WHERE company_id = $1 AND enabled = true`,
|
||||
[SEUBERT_COMPANY_ID]
|
||||
);
|
||||
const tenant = tenantRow.rows[0];
|
||||
if (!tenant) {
|
||||
console.log('No enabled Mimecast tenant for Seubert — cannot verify.');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`Using Mimecast tenant: ${tenant.account_name}`);
|
||||
|
||||
const client = getMimecastClientForTenant({
|
||||
client_id: tenant.client_id,
|
||||
client_secret: tenant.client_secret,
|
||||
base_url: tenant.base_url ?? undefined,
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const start = new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000); // 5 days back
|
||||
const startStr = start.toISOString().replace(/\.\d{3}Z$/, '+0000');
|
||||
const endStr = now.toISOString().replace(/\.\d{3}Z$/, '+0000');
|
||||
|
||||
console.log(`\n=== 1. Exact sender+recipient+subject match ===`);
|
||||
console.log(`from=${REPORTED_SENDER} to=${RECIPIENT} subject="${SUBJECT}" window=${startStr}..${endStr}`);
|
||||
const exact = await client.searchDeliveredMessages({
|
||||
from: REPORTED_SENDER,
|
||||
to: RECIPIENT,
|
||||
subject: SUBJECT,
|
||||
start: startStr,
|
||||
end: endStr,
|
||||
});
|
||||
console.log(JSON.stringify(exact, null, 2));
|
||||
|
||||
console.log(`\n=== 2. Sender domain only (it-support.care), any recipient at seubert.com ===`);
|
||||
const domainOnly = await client.searchDeliveredMessages({
|
||||
from: 'it-support.care',
|
||||
to: 'seubert.com',
|
||||
start: startStr,
|
||||
end: endStr,
|
||||
});
|
||||
console.log(JSON.stringify(domainOnly, null, 2));
|
||||
|
||||
console.log(`\n=== 3. All mail TO Rich in the window (no sender filter) ===`);
|
||||
const toRich = await client.searchDeliveredMessages({
|
||||
to: RECIPIENT,
|
||||
subject: SUBJECT,
|
||||
start: startStr,
|
||||
end: endStr,
|
||||
});
|
||||
console.log(JSON.stringify(toRich, null, 2));
|
||||
|
||||
console.log(`\n=== 4. Held messages for Rich ===`);
|
||||
const held = await client.getHeldMessages({ recipient: RECIPIENT });
|
||||
console.log(JSON.stringify(held, null, 2));
|
||||
|
||||
console.log(`\n=== 5. Recent threat events on this tenant (last 500) ===`);
|
||||
const threats = await client.getThreatEvents({ pageSize: 500 });
|
||||
const relevant = threats.items.filter(
|
||||
(t) => (t.actorEmail ?? '').toLowerCase().includes('it-support.care')
|
||||
);
|
||||
console.log(`Total threat events fetched: ${threats.items.length}`);
|
||||
console.log(`Events involving it-support.care: ${relevant.length}`);
|
||||
console.log(JSON.stringify(relevant, null, 2));
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue