Skip to content

Add dashboard, mail sources, setup wizard, and split CLI commands#12

Merged
linyows merged 9 commits into
mainfrom
add-dashboard-and-enrich-cache
Apr 16, 2026
Merged

Add dashboard, mail sources, setup wizard, and split CLI commands#12
linyows merged 9 commits into
mainfrom
add-dashboard-and-enrich-cache

Conversation

@linyows

@linyows linyows commented Apr 16, 2026

Copy link
Copy Markdown
Owner

Summary

  • Split CLI commands: Decompose monolithic fetch into sync, fetch, enrich, aggregate for single-responsibility and independent execution
  • Zig C APIs with caching: Add reports_dashboard, reports_sources, reports_fetch_account APIs with JSON file caching (.dashboard_cache.json, .sources_cache.json) for instant UI loading
  • Neon color palette: Brand-aligned neon yellow/pink/green colors matching logo rgb(194,255,38), with dark mode support
  • Problems tracking: Per-report problem count computed in Zig core, displayed in report list column and sidebar filter
  • Mail Sources view: Cross-report IP analysis with PTR/ASN/country enrichment data embedded in cached JSON
  • Dashboard improvements: Per-domain disposition, policy type, and failure type charts with color-matched legends and responsive 2-column layout at 1600px+
  • Setup wizard: 4-step account configuration wizard with live sync progress, curl error display, and retry/back flow
  • Account management: Add Account button in sidebar and menu bar (⇧⌘N), existing config preservation on account addition

Test plan

  • Run reports sync and verify fetch → enrich → aggregate executes in sequence
  • Run reports fetch, reports enrich, reports aggregate independently
  • Open macOS app and verify dashboard loads instantly from cache
  • Check Mail Sources view shows all IPs with enrichment data
  • Verify Problems sidebar filter shows only reports with issues
  • Test setup wizard with valid and invalid credentials
  • Test Add Account from sidebar button and ⇧⌘N shortcut
  • Verify neon colors render correctly in both light and dark mode
  • Check responsive 2-column dashboard layout at wide window widths

🤖 Generated with Claude Code

linyows and others added 5 commits April 16, 2026 23:38
…s C APIs

- Split monolithic fetch into four commands: sync, fetch, enrich, aggregate
- Add reports_dashboard API with JSON caching (.dashboard_cache.json)
- Add reports_sources API with enrichment data and caching (.sources_cache.json)
- Add reports_fetch_account API for single-account fetch with curl error messages
- Add per-report problems count to list API output
- Track curl error messages via global last_curl_error for better diagnostics
- Return -1 from fetch when all accounts fail IMAP auth/connection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…oard improvements

- Add neon color palette (passGreen, failRed, neonYellow, barText) with dark mode support
- Add problems column to report list and problems filter to sidebar
- Add Mail Sources view with cross-report IP analysis (PTR/ASN/country from cache)
- Move dashboard stats computation from Swift to Zig C API (reports_dashboard)
- Move mail sources computation from Swift to Zig C API (reports_sources)
- Add per-domain disposition, policy type, and failure type charts to dashboard
- Add pass/fail legends to all bar charts with color-matched text
- Responsive 2-column layout for dashboard charts at 1600px+ width
- Use orange/blue type badges for DMARC/TLS-RPT in report list
- Replace system green/red with neon colors across all views
- Pre-load mail sources in background on app launch

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Show setup wizard when no accounts are configured
- 4-step flow: credentials, server settings, account name, sync progress
- Auto-detect IMAP host and account name from email domain
- Trim whitespace from all input fields before saving
- Run fetch/enrich/aggregate for the new account with progress display
- Show curl error messages on connection/auth failure with retry option
- Remove failed account from config when going back to edit settings
- Add account button in sidebar and menu bar (⇧⌘N)
- Add Reports logo SVG to asset catalog for wizard branding

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Self-review cleanup: these were replaced by per-domain disposition and
domainKVChart views but the old definitions were left behind.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move DMARC/TLS-RPT problem counting logic from lib.zig and main.zig into
a new stats.zig module with pure functions that take JSON data directly.
This makes the logic testable without file I/O dependencies.

Tests cover: empty records, all-pass, mixed pass/fail, missing fields,
invalid JSON, zero failures, and multi-policy summation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR expands the reports app/CLI by splitting the “fetch” workflow into discrete phases, adding new Zig C-ABI aggregation APIs with on-disk JSON caching for instant UI load, and introducing new macOS UI surfaces (dashboard enhancements, problems tracking, mail sources, and an account setup wizard).

Changes:

  • Split the CLI workflow into sync, fetch, enrich, and aggregate, plus add a per-account fetch API for the setup wizard.
  • Add new Zig C APIs (reports_dashboard, reports_sources, reports_fetch_account) and cache files (.dashboard_cache.json, .sources_cache.json) to support fast macOS UI loading.
  • Update macOS UI with a setup wizard, problems filtering, a mail sources view, and refreshed dashboard charts/colors.

Reviewed changes

Copilot reviewed 19 out of 20 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
src/reports.h Exposes new C-ABI functions for per-account fetch and dashboard/sources aggregation.
src/main.zig Adds sync/enrich/aggregate commands and updates list JSON output to include problems.
src/lib.zig Implements new exported C APIs, dashboard/sources cache builders, and “problems” computation in list output.
src/imap.zig Adds a “last curl error” accessor intended for surfacing IMAP errors in the setup wizard.
macos/Reports/Views/SidebarView.swift Adds Problems and Mail Sources navigation plus an Add Account button.
macos/Reports/Views/SetupWizardView.swift New 4-step wizard for adding an account and running fetch/enrich/aggregate with progress + retry.
macos/Reports/Views/SettingsView.swift Updates status indicator colors to semantic palette.
macos/Reports/Views/ReportListView.swift Adds a Problems column and updates the navigation title for Problems filtering.
macos/Reports/Views/ReportDetailView.swift Updates pass/fail styling to semantic palette colors.
macos/Reports/Views/MailSourcesView.swift New table view for cross-report source IP analysis with enrichment fields.
macos/Reports/Views/DashboardView.swift Switches dashboard aggregation to Zig API/caches; adds new per-domain charts and responsive layout.
macos/Reports/Views/ContentView.swift Shows setup wizard when no accounts; adds Add Account sheet presentation; centralizes error alert.
macos/Reports/ReportsViewModel.swift Adds problems filtering, mail sources loading, “hasAccounts” detection, and switches fetch flow to sync.
macos/Reports/ReportsCore.swift Adds Swift wrappers for fetchAccount/enrich/aggregate/sync/dashboard/sources APIs and new error cases.
macos/Reports/ReportsApp.swift Adds Add Account menu command and renames Fetch to Sync.
macos/Reports/Models.swift Introduces semantic neon palette colors and adds models for dashboard + mail sources JSON.
macos/Reports/Assets.xcassets/Logo.imageset/reports.svg Adds/updates vector logo asset.
macos/Reports/Assets.xcassets/Logo.imageset/Contents.json Adds asset catalog entry for the SVG logo.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/lib.zig
Comment on lines +495 to +545
while (it.next()) |kv| {
if (!first) buf.appendSlice(allocator, ",") catch return null;
first = false;
const dom = kv.key_ptr.*;

// Collect policy types for this domain
var pt_buf: std.ArrayList(u8) = .empty;
defer pt_buf.deinit(allocator);
pt_buf.appendSlice(allocator, "[") catch continue;
{
var pt_first = true;
var pt_it = domain_policy_types.iterator();
while (pt_it.next()) |pt_kv| {
const compound = pt_kv.key_ptr.*;
if (std.mem.indexOfScalar(u8, compound, 0)) |sep| {
if (std.mem.eql(u8, compound[0..sep], dom)) {
if (!pt_first) pt_buf.appendSlice(allocator, ",") catch continue;
pt_first = false;
const entry = std.fmt.allocPrint(allocator, "{{\"k\":\"{s}\",\"v\":{d}}}", .{ compound[sep + 1 ..], pt_kv.value_ptr.* }) catch continue;
defer allocator.free(entry);
pt_buf.appendSlice(allocator, entry) catch continue;
}
}
}
}
pt_buf.appendSlice(allocator, "]") catch continue;

// Collect failure types for this domain
var ft_buf: std.ArrayList(u8) = .empty;
defer ft_buf.deinit(allocator);
ft_buf.appendSlice(allocator, "[") catch continue;
{
var ft_first = true;
var ft_it = domain_failure_types.iterator();
while (ft_it.next()) |ft_kv| {
const compound = ft_kv.key_ptr.*;
if (std.mem.indexOfScalar(u8, compound, 0)) |sep| {
if (std.mem.eql(u8, compound[0..sep], dom)) {
if (!ft_first) ft_buf.appendSlice(allocator, ",") catch continue;
ft_first = false;
const entry = std.fmt.allocPrint(allocator, "{{\"k\":\"{s}\",\"v\":{d}}}", .{ compound[sep + 1 ..], ft_kv.value_ptr.* }) catch continue;
defer allocator.free(entry);
ft_buf.appendSlice(allocator, entry) catch continue;
}
}
}
}
ft_buf.appendSlice(allocator, "]") catch continue;

const line = std.fmt.allocPrint(allocator, "{{\"domain\":\"{s}\",\"success\":{d},\"failure\":{d},\"policy_types\":{s},\"failure_types\":{s}}}", .{
dom, kv.value_ptr.success, kv.value_ptr.failure, pt_buf.items, ft_buf.items,

Copilot AI Apr 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The per-domain policy/failure-type rendering is O(domains × (all policy/failure keys)) because it iterates the entire domain_policy_types / domain_failure_types map for every domain. With many domains/types this can become a noticeable bottleneck during cache rebuild. Consider grouping keys by domain once (e.g., a StringHashMap([]KV) keyed by domain) to make this linear overall.

Suggested change
while (it.next()) |kv| {
if (!first) buf.appendSlice(allocator, ",") catch return null;
first = false;
const dom = kv.key_ptr.*;
// Collect policy types for this domain
var pt_buf: std.ArrayList(u8) = .empty;
defer pt_buf.deinit(allocator);
pt_buf.appendSlice(allocator, "[") catch continue;
{
var pt_first = true;
var pt_it = domain_policy_types.iterator();
while (pt_it.next()) |pt_kv| {
const compound = pt_kv.key_ptr.*;
if (std.mem.indexOfScalar(u8, compound, 0)) |sep| {
if (std.mem.eql(u8, compound[0..sep], dom)) {
if (!pt_first) pt_buf.appendSlice(allocator, ",") catch continue;
pt_first = false;
const entry = std.fmt.allocPrint(allocator, "{{\"k\":\"{s}\",\"v\":{d}}}", .{ compound[sep + 1 ..], pt_kv.value_ptr.* }) catch continue;
defer allocator.free(entry);
pt_buf.appendSlice(allocator, entry) catch continue;
}
}
}
}
pt_buf.appendSlice(allocator, "]") catch continue;
// Collect failure types for this domain
var ft_buf: std.ArrayList(u8) = .empty;
defer ft_buf.deinit(allocator);
ft_buf.appendSlice(allocator, "[") catch continue;
{
var ft_first = true;
var ft_it = domain_failure_types.iterator();
while (ft_it.next()) |ft_kv| {
const compound = ft_kv.key_ptr.*;
if (std.mem.indexOfScalar(u8, compound, 0)) |sep| {
if (std.mem.eql(u8, compound[0..sep], dom)) {
if (!ft_first) ft_buf.appendSlice(allocator, ",") catch continue;
ft_first = false;
const entry = std.fmt.allocPrint(allocator, "{{\"k\":\"{s}\",\"v\":{d}}}", .{ compound[sep + 1 ..], ft_kv.value_ptr.* }) catch continue;
defer allocator.free(entry);
ft_buf.appendSlice(allocator, entry) catch continue;
}
}
}
}
ft_buf.appendSlice(allocator, "]") catch continue;
const line = std.fmt.allocPrint(allocator, "{{\"domain\":\"{s}\",\"success\":{d},\"failure\":{d},\"policy_types\":{s},\"failure_types\":{s}}}", .{
dom, kv.value_ptr.success, kv.value_ptr.failure, pt_buf.items, ft_buf.items,
var policy_types_by_domain = std.StringHashMap(std.ArrayList(u8)).init(allocator);
defer {
var grouped_it = policy_types_by_domain.iterator();
while (grouped_it.next()) |entry| {
entry.value_ptr.deinit(allocator);
}
policy_types_by_domain.deinit();
}
{
var pt_it = domain_policy_types.iterator();
while (pt_it.next()) |pt_kv| {
const compound = pt_kv.key_ptr.*;
const sep = std.mem.indexOfScalar(u8, compound, 0) orelse continue;
const dom = compound[0..sep];
const type_name = compound[sep + 1 ..];
const gop = policy_types_by_domain.getOrPut(dom) catch continue;
if (!gop.found_existing) {
gop.value_ptr.* = .empty;
gop.value_ptr.appendSlice(allocator, "[") catch {
_ = policy_types_by_domain.remove(dom);
continue;
};
}
if (gop.value_ptr.items.len > 1) gop.value_ptr.appendSlice(allocator, ",") catch continue;
const entry = std.fmt.allocPrint(allocator, "{{\"k\":\"{s}\",\"v\":{d}}}", .{ type_name, pt_kv.value_ptr.* }) catch continue;
defer allocator.free(entry);
gop.value_ptr.appendSlice(allocator, entry) catch continue;
}
var grouped_it = policy_types_by_domain.iterator();
while (grouped_it.next()) |entry| {
entry.value_ptr.appendSlice(allocator, "]") catch continue;
}
}
var failure_types_by_domain = std.StringHashMap(std.ArrayList(u8)).init(allocator);
defer {
var grouped_it = failure_types_by_domain.iterator();
while (grouped_it.next()) |entry| {
entry.value_ptr.deinit(allocator);
}
failure_types_by_domain.deinit();
}
{
var ft_it = domain_failure_types.iterator();
while (ft_it.next()) |ft_kv| {
const compound = ft_kv.key_ptr.*;
const sep = std.mem.indexOfScalar(u8, compound, 0) orelse continue;
const dom = compound[0..sep];
const type_name = compound[sep + 1 ..];
const gop = failure_types_by_domain.getOrPut(dom) catch continue;
if (!gop.found_existing) {
gop.value_ptr.* = .empty;
gop.value_ptr.appendSlice(allocator, "[") catch {
_ = failure_types_by_domain.remove(dom);
continue;
};
}
if (gop.value_ptr.items.len > 1) gop.value_ptr.appendSlice(allocator, ",") catch continue;
const entry = std.fmt.allocPrint(allocator, "{{\"k\":\"{s}\",\"v\":{d}}}", .{ type_name, ft_kv.value_ptr.* }) catch continue;
defer allocator.free(entry);
gop.value_ptr.appendSlice(allocator, entry) catch continue;
}
var grouped_it = failure_types_by_domain.iterator();
while (grouped_it.next()) |entry| {
entry.value_ptr.appendSlice(allocator, "]") catch continue;
}
}
while (it.next()) |kv| {
if (!first) buf.appendSlice(allocator, ",") catch return null;
first = false;
const dom = kv.key_ptr.*;
const pt_json = if (policy_types_by_domain.get(dom)) |pt| pt.items else "[]";
const ft_json = if (failure_types_by_domain.get(dom)) |ft| ft.items else "[]";
const line = std.fmt.allocPrint(allocator, "{{\"domain\":\"{s}\",\"success\":{d},\"failure\":{d},\"policy_types\":{s},\"failure_types\":{s}}}", .{
dom, kv.value_ptr.success, kv.value_ptr.failure, pt_json, ft_json,

Copilot uses AI. Check for mistakes.
Comment on lines +17 to +21
.alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) {
Button("OK") { viewModel.errorMessage = nil }
} message: {
Text(viewModel.errorMessage ?? "")
}

Copilot AI Apr 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using .alert(..., isPresented: .constant(viewModel.errorMessage != nil)) creates a non-settable binding. If the user dismisses the alert via Escape/clicking outside, SwiftUI will try to set the binding to false and it will be ignored, potentially causing the alert to reappear because errorMessage is unchanged. Prefer a binding that clears errorMessage in its setter (or a dedicated @Published var showError).

Copilot uses AI. Check for mistakes.
Comment on lines +389 to +418
// Append new account to existing accounts array
var accounts = (config["accounts"] as? [[String: Any]]) ?? []
accounts.append(newAccount)
config["accounts"] = accounts

let data = try JSONSerialization.data(withJSONObject: config, options: [.prettyPrinted, .sortedKeys])
try data.write(to: configPath)
} catch {
errorMessage = error.localizedDescription
isSaving = false
return
}

withAnimation { step = 3 }
startSyncTask()
}

private func removeAccountFromConfig() {
let configPath = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".config/reports/config.json")
guard let data = try? Data(contentsOf: configPath),
var config = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
var accounts = config["accounts"] as? [[String: Any]] else { return }

accounts.removeAll { ($0["name"] as? String) == accountName }
config["accounts"] = accounts

if let newData = try? JSONSerialization.data(withJSONObject: config, options: [.prettyPrinted, .sortedKeys]) {
try? newData.write(to: configPath)
}

Copilot AI Apr 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When saving a new account, the wizard always appends to the existing accounts array and rollback removes all accounts matching accountName. If a user picks a name that already exists, this can create duplicates and the rollback path can delete the pre-existing account(s). Consider validating uniqueness up front (disable/inline error), or replacing an existing account with the same name, and making rollback target only the newly-added entry.

Copilot uses AI. Check for mistakes.
Comment on lines +6 to +18
extension Color {
/// Neon yellow for pass/success — matches logo rgb(194,255,38).
static let passGreen = Color(light: .init(red: 0.50, green: 0.66, blue: 0.10),
dark: .init(red: 0.76, green: 1.00, blue: 0.15))
/// Neon pink for fail — electric magenta-pink.
static let failRed = Color(red: 1.00, green: 0.20, blue: 0.60)
/// Problem badge — neon pink-red, slightly muted for badge use.
static let problemRed = Color(light: .init(red: 0.70, green: 0.10, blue: 0.15),
dark: .init(red: 0.95, green: 0.30, blue: 0.35))
/// Neon yellow matching the logo rgb(194,255,38) — for donut charts and accents.
static let neonYellow = Color(light: .init(red: 0.50, green: 0.66, blue: 0.10),
dark: .init(red: 0.76, green: 1.00, blue: 0.15))
/// Bar chart overlay text — white on light (dark bars), black on dark (bright neon bars).

Copilot AI Apr 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

passGreen is documented as “Neon yellow” and uses yellow-ish RGB values, so the name is misleading (and there’s also a separate neonYellow with identical values). Consider renaming to reflect the actual semantic/color (e.g., passYellow or reusing neonYellow), to avoid confusion when applying colors elsewhere.

Copilot uses AI. Check for mistakes.
Comment thread src/lib.zig
Comment on lines +840 to +844
const line = std.fmt.allocPrint(allocator, "{{\"ip\":\"{s}\",\"messages\":{d},\"dmarc_issues\":{d},\"tls_failures\":{d},\"types\":{s},\"domains\":{s},\"ptr\":\"{s}\",\"asn\":\"{s}\",\"asn_org\":\"{s}\",\"country\":\"{s}\"}}", .{
kv.key_ptr.*,
kv.value_ptr.messages,
kv.value_ptr.dmarc_issues,
kv.value_ptr.tls_failures,

Copilot AI Apr 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

buildSourcesJson interpolates ptr_str / asn_org_str / etc directly into a JSON string via {s} without escaping. These fields can contain quotes/backslashes (see src/ipinfo.zig test "toJson escapes special characters"), which will produce invalid JSON and break Swift decoding. Please JSON-escape these strings (e.g., std.json.stringify / std.json.escapeString) or reuse the existing IpInfo.toJson escaping logic when embedding enrichment fields.

Copilot uses AI. Check for mistakes.
Comment thread src/lib.zig Outdated
Comment on lines +191 to +192
_ = reports_enrich(config_json);
_ = reports_aggregate(config_json);

Copilot AI Apr 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reports_sync ignores the return codes from reports_enrich and reports_aggregate, but still returns 0. That means sync can report success even when enrichment/aggregation fails. Please check each phase and return -1 if any step fails (or return distinct error codes if you want finer-grained UI errors).

Suggested change
_ = reports_enrich(config_json);
_ = reports_aggregate(config_json);
if (reports_enrich(config_json) != 0) return -1;
if (reports_aggregate(config_json) != 0) return -1;

Copilot uses AI. Check for mistakes.
Comment thread src/main.zig
Comment on lines +124 to +142
fn cmdAggregate(allocator: std.mem.Allocator) !void {
const cfg = try Config.load(allocator);
defer cfg.deinit(allocator);

const names = cfg.accountNames(allocator) catch return;
defer allocator.free(names);

const ips = reports.fetch.collectSourceIps(allocator, cfg.data_dir, names) catch return;
defer reports.fetch.freeIpList(allocator, ips);

const entries = reports.store.listAllReports(allocator, cfg.data_dir, names) catch return;
defer reports.store.freeReportEntries(allocator, entries);

// Delete old caches so they are rebuilt on next access
for ([_][]const u8{ ".sources_cache.json", ".dashboard_cache.json" }) |filename| {
const path = std.fs.path.join(allocator, &.{ cfg.data_dir, filename }) catch continue;
defer allocator.free(path);
std.fs.deleteFileAbsolute(path) catch {};
}

Copilot AI Apr 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cmdAggregate is described as rebuilding the dashboard and mail sources caches, but it only deletes the cache files and prints counts; it never actually regenerates the caches. This makes reports aggregate a no-op until something later triggers lazy rebuild. Consider invoking the same builders used by the C API (or directly calling the dashboard/sources aggregation functions) so the command does what the usage text promises.

Copilot uses AI. Check for mistakes.
Comment thread src/lib.zig
Comment on lines +572 to +584
const prefix = std.fmt.allocPrint(allocator, "\"{s}\":[", .{name}) catch return;
defer allocator.free(prefix);
buf.appendSlice(allocator, prefix) catch return;
var first = true;
var it = map.iterator();
while (it.next()) |kv| {
if (!first) buf.appendSlice(allocator, ",") catch return;
first = false;
const line = std.fmt.allocPrint(allocator, "{{\"k\":\"{s}\",\"v\":{d}}}", .{ kv.key_ptr.*, kv.value_ptr.* }) catch continue;
defer allocator.free(line);
buf.appendSlice(allocator, line) catch return;
}
buf.appendSlice(allocator, "]") catch return;

Copilot AI Apr 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

writeMapArray silently returns/continues on allocation/append failures, which can leave the surrounding JSON buffer in a malformed state (e.g., commas appended without a subsequent entry). Since this output is parsed by Swift, it’s safer to propagate an error up (return !void / ?void) and abort JSON construction on failure rather than emitting partial/invalid JSON.

Suggested change
const prefix = std.fmt.allocPrint(allocator, "\"{s}\":[", .{name}) catch return;
defer allocator.free(prefix);
buf.appendSlice(allocator, prefix) catch return;
var first = true;
var it = map.iterator();
while (it.next()) |kv| {
if (!first) buf.appendSlice(allocator, ",") catch return;
first = false;
const line = std.fmt.allocPrint(allocator, "{{\"k\":\"{s}\",\"v\":{d}}}", .{ kv.key_ptr.*, kv.value_ptr.* }) catch continue;
defer allocator.free(line);
buf.appendSlice(allocator, line) catch return;
}
buf.appendSlice(allocator, "]") catch return;
var tmp = std.ArrayList(u8){};
defer tmp.deinit(allocator);
const prefix = std.fmt.allocPrint(allocator, "\"{s}\":[", .{name}) catch return;
defer allocator.free(prefix);
tmp.appendSlice(allocator, prefix) catch return;
var first = true;
var it = map.iterator();
while (it.next()) |kv| {
if (!first) tmp.appendSlice(allocator, ",") catch return;
first = false;
const line = std.fmt.allocPrint(
allocator,
"{{\"k\":\"{s}\",\"v\":{d}}}",
.{ kv.key_ptr.*, kv.value_ptr.* },
) catch return;
defer allocator.free(line);
tmp.appendSlice(allocator, line) catch return;
}
tmp.appendSlice(allocator, "]") catch return;
buf.appendSlice(allocator, tmp.items) catch return;

Copilot uses AI. Check for mistakes.
Comment thread src/imap.zig
Comment on lines +8 to +13
/// Last curl error message (static string from curl_easy_strerror, no need to free).
var g_last_curl_error: ?[]const u8 = null;

pub fn lastCurlError() ?[]const u8 {
return g_last_curl_error;
}

Copilot AI Apr 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

g_last_curl_error is a mutable global that gets written from Client.doPerform. IMAP fetch runs in multiple threads (see src/fetch.zig worker pool), so this introduces a data race and undefined behavior. Consider moving the error message to per-Client state, making it threadlocal, or protecting it with a mutex (and resetting it at the start of each request) so concurrent fetches can't clobber it.

Copilot uses AI. Check for mistakes.
Comment thread src/lib.zig Outdated
Comment on lines +183 to +184
_ = buildAndCacheDashboard(cfg.data_dir, names);
_ = buildAndCacheSourcesWithNames(cfg.data_dir, names);

Copilot AI Apr 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reports_aggregate always returns 0 even if buildAndCacheDashboard / buildAndCacheSourcesWithNames fails (both return nullable pointers). Since Swift treats a non-zero result as failure, this currently masks aggregation errors. Please propagate failures (e.g., return -1 when either builder returns null) so callers can surface errors correctly.

Suggested change
_ = buildAndCacheDashboard(cfg.data_dir, names);
_ = buildAndCacheSourcesWithNames(cfg.data_dir, names);
if (buildAndCacheDashboard(cfg.data_dir, names) == null) return -1;
if (buildAndCacheSourcesWithNames(cfg.data_dir, names) == null) return -1;

Copilot uses AI. Check for mistakes.
linyows and others added 3 commits April 17, 2026 00:10
Move DMARC/TLS-RPT aggregation functions from lib.zig into stats.zig:
- aggregateDmarcReport: accumulates auth stats and dispositions from JSON
- aggregateTlsReport: accumulates TLS sessions, policy/failure types from JSON
- hashIncOwned/hashIncOwnedPrealloc: owned-key hash map incrementers
- freeMapKeys: cleanup helper
- DashAgg, TlsDomAgg, DmarcDashJson, TlsDashJson: shared types

All functions are pure (no file I/O) and take allocator + JSON data directly.
lib.zig now delegates to stats module, removing ~100 lines of inline logic.

New tests: DMARC auth/disposition aggregation, TLS session/policy/failure
aggregation, fallback domain handling, invalid JSON resilience,
hashIncOwned increment behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Tests for the most critical behavior: multiple reports aggregating into
the same domain counters, separate domains staying independent, multiple
policy/failure types per domain, compound key correctness, and empty
string edge case.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- JSON-escape enrichment strings (ptr, asn_org) in sources cache to
  prevent invalid JSON when fields contain quotes or backslashes
- Propagate error codes from enrich/aggregate in reports_sync
- Propagate null returns from cache builders in reports_aggregate
- Make g_last_curl_error threadlocal to prevent data races in parallel fetch
- Clarify cmdAggregate as cache invalidation (CLI cannot call lib.zig exports)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@linyows linyows merged commit 10ac9d9 into main Apr 16, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants