diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0649261..ed10c11 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,8 +8,10 @@ on: # Pin tool versions in one place for reproducibility. env: GO_VERSION: "1.24.x" - NODE_VERSION: "20" + NODE_VERSION: "22.23.1" WAILS_VERSION: "v2.11.0" + GOLANGCI_LINT_VERSION: "v2.11.4" + CYCLONEDX_GOMOD_VERSION: "v1.10.0" permissions: contents: read @@ -28,20 +30,45 @@ jobs: run: | unformatted=$(gofmt -l $(git ls-files '*.go')) if [ -n "$unformatted" ]; then - echo "These files are not gofmt-clean:"; echo "$unformatted"; exit 1 + echo "These files are not gofmt-clean:" + echo "$unformatted" + exit 1 fi - name: go vet run: go vet ./backend/... - - name: go test (race) - run: go test -race ./backend/... + - name: go test (race + coverage) + run: go test -race -covermode=atomic -coverprofile=backend-coverage.out ./backend/... + - name: Upload backend coverage + if: always() + uses: actions/upload-artifact@v4 + with: + name: backend-coverage + path: backend-coverage.out + if-no-files-found: ignore + retention-days: 14 - name: golangci-lint - uses: golangci/golangci-lint-action@v6 + id: golangci + continue-on-error: true + uses: golangci/golangci-lint-action@v9 + with: + version: ${{ env.GOLANGCI_LINT_VERSION }} + args: --output.text.path=golangci-lint.txt ./backend/... + - name: Upload golangci-lint diagnostics + if: always() + uses: actions/upload-artifact@v4 with: - version: v1.61.0 - args: ./backend/... + name: golangci-lint-diagnostics + path: golangci-lint.txt + if-no-files-found: ignore + retention-days: 7 + - name: Enforce golangci-lint gate + if: steps.golangci.outcome == 'failure' + run: | + cat golangci-lint.txt 2>/dev/null || true + exit 1 frontend: - name: Frontend (lint, test, build) + name: Frontend (test, coverage, build) runs-on: ubuntu-latest defaults: run: @@ -55,8 +82,29 @@ jobs: cache-dependency-path: frontend/package-lock.json - run: npm ci - run: npm run lint --if-present - - run: npm run test --if-present - - run: npm run build + - name: Frontend tests with coverage + run: npm run test -- --coverage + - name: Upload frontend coverage + if: always() + uses: actions/upload-artifact@v4 + with: + name: frontend-coverage + path: frontend/coverage + if-no-files-found: ignore + retention-days: 14 + - name: Frontend production build + shell: bash + run: | + set -o pipefail + npm run build 2>&1 | tee ../frontend-build.log + - name: Upload frontend build diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: frontend-build-diagnostics + path: frontend-build.log + if-no-files-found: ignore + retention-days: 7 windows-build: name: Wails Windows build @@ -78,13 +126,38 @@ jobs: working-directory: frontend run: npm ci - name: Build (unsigned) - run: wails build -platform windows/amd64 -skipbindings -ldflags "-X main.Version=ci-${{ github.sha }} -X main.Commit=${{ github.sha }}" + shell: pwsh + run: | + wails build -platform windows/amd64 -skipbindings -ldflags "-X main.Version=ci-${{ github.sha }} -X main.Commit=${{ github.sha }}" 2>&1 | Tee-Object -FilePath wails-build.log + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Upload Wails build diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: wails-windows-build-diagnostics + path: wails-build.log + if-no-files-found: ignore + retention-days: 7 + - name: Root package tests + shell: pwsh + run: | + go test . 2>&1 | Tee-Object -FilePath root-package-tests.log + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Upload root package test diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: root-package-test-diagnostics + path: root-package-tests.log + if-no-files-found: ignore + retention-days: 7 - name: Upload artifact uses: actions/upload-artifact@v4 with: name: MailMergeApp-windows-unsigned path: build/bin/*.exe if-no-files-found: error + retention-days: 14 security: name: Dependency scan @@ -95,12 +168,63 @@ jobs: with: go-version: ${{ env.GO_VERSION }} cache: true + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: frontend/package-lock.json - name: govulncheck + shell: bash run: | go install golang.org/x/vuln/cmd/govulncheck@latest - govulncheck ./backend/... || true - - name: npm audit + set +e + govulncheck -json ./backend/... > govulncheck.json + status=$? + cat govulncheck.json + exit $status + - name: Upload govulncheck report + if: always() + uses: actions/upload-artifact@v4 + with: + name: govulncheck-report + path: govulncheck.json + if-no-files-found: ignore + retention-days: 14 + - name: npm production audit working-directory: frontend run: | npm ci - npm audit --omit=dev || true + npm audit --omit=dev --audit-level=high + + sbom: + name: Generate SBOMs + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + cache: true + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: frontend/package-lock.json + - name: Generate Go CycloneDX SBOM + run: | + go install github.com/CycloneDX/cyclonedx-gomod/cmd/cyclonedx-gomod@${{ env.CYCLONEDX_GOMOD_VERSION }} + cyclonedx-gomod mod -json -output backend.cdx.json + - name: Generate frontend CycloneDX SBOM + working-directory: frontend + run: | + npm ci + npm sbom --omit=dev --sbom-format=cyclonedx > ../frontend.cdx.json + - name: Upload SBOMs + uses: actions/upload-artifact@v4 + with: + name: cyclonedx-sboms + path: | + backend.cdx.json + frontend.cdx.json + if-no-files-found: error + retention-days: 30 diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml new file mode 100644 index 0000000..5503be3 --- /dev/null +++ b/.github/workflows/release-candidate.yml @@ -0,0 +1,123 @@ +name: Release Candidate + +on: + workflow_dispatch: + inputs: + label: + description: Optional release-candidate label, for example 1.2.0-rc.1 + required: false + type: string + +permissions: + contents: read + +env: + GO_VERSION: "1.24.x" + NODE_VERSION: "22.23.1" + WAILS_VERSION: "v2.11.0" + GOLANGCI_LINT_VERSION: "v2.11.4" + CYCLONEDX_GOMOD_VERSION: "v1.10.0" + +jobs: + validate: + name: Validate release candidate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + cache: true + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: frontend/package-lock.json + - name: Go formatting + shell: bash + run: | + unformatted=$(gofmt -l $(git ls-files '*.go')) + test -z "$unformatted" || { echo "$unformatted"; exit 1; } + - name: Go vet + run: go vet ./backend/... + - name: Go race tests and coverage + run: go test -race -covermode=atomic -coverprofile=backend-coverage.out ./backend/... + - name: Go lint + uses: golangci/golangci-lint-action@v9 + with: + version: ${{ env.GOLANGCI_LINT_VERSION }} + args: ./backend/... + - name: Go vulnerability scan + run: | + go install golang.org/x/vuln/cmd/govulncheck@latest + govulncheck ./backend/... + - name: Frontend install, test, and build + working-directory: frontend + run: | + npm ci + npm run lint --if-present + npm run test -- --coverage + npm run build + npm audit --omit=dev --audit-level=high + - name: Generate CycloneDX SBOMs + run: | + go install github.com/CycloneDX/cyclonedx-gomod/cmd/cyclonedx-gomod@${{ env.CYCLONEDX_GOMOD_VERSION }} + cyclonedx-gomod mod -json -output backend.cdx.json + cd frontend + npm sbom --omit=dev --sbom-format=cyclonedx > ../frontend.cdx.json + - name: Upload validation evidence + uses: actions/upload-artifact@v4 + with: + name: release-candidate-validation + path: | + backend-coverage.out + frontend/coverage + backend.cdx.json + frontend.cdx.json + if-no-files-found: error + retention-days: 30 + + windows-package: + name: Build Windows release candidate + needs: validate + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + cache: true + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: frontend/package-lock.json + - name: Install Wails CLI + run: go install github.com/wailsapp/wails/v2/cmd/wails@${{ env.WAILS_VERSION }} + - name: Install frontend dependencies + working-directory: frontend + run: npm ci + - name: Build release candidate + shell: pwsh + run: | + $label = "${{ inputs.label }}" + if ([string]::IsNullOrWhiteSpace($label)) { $label = "rc-${{ github.run_number }}" } + $date = (Get-Date -Format "yyyy-MM-dd") + wails build -platform windows/amd64 -ldflags "-X main.Version=$label -X main.Commit=${{ github.sha }} -X main.BuildDate=$date" + - name: Root package tests + run: go test . + - name: Create checksums + shell: pwsh + run: | + Get-ChildItem build/bin/*.exe | Sort-Object Name | ForEach-Object { + (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + " " + $_.Name + } | Out-File build/bin/SHA256SUMS.txt -Encoding ascii + - name: Upload release candidate + uses: actions/upload-artifact@v4 + with: + name: MailMergeApp-release-candidate + path: | + build/bin/*.exe + build/bin/SHA256SUMS.txt + if-no-files-found: error + retention-days: 30 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 54a92eb..082077e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,8 +11,11 @@ permissions: env: GO_VERSION: "1.24.x" - NODE_VERSION: "20" + NODE_VERSION: "22.23.1" WAILS_VERSION: "v2.11.0" + CYCLONEDX_GOMOD_VERSION: "v1.10.0" + WINDOWS_CERT_PFX_BASE64: ${{ secrets.WINDOWS_CERT_PFX_BASE64 }} + WINDOWS_CERT_PASSWORD: ${{ secrets.WINDOWS_CERT_PASSWORD }} jobs: release: @@ -28,8 +31,11 @@ jobs: node-version: ${{ env.NODE_VERSION }} cache: npm cache-dependency-path: frontend/package-lock.json - - name: Install Wails CLI - run: go install github.com/wailsapp/wails/v2/cmd/wails@${{ env.WAILS_VERSION }} + - name: Install release tools + shell: pwsh + run: | + go install github.com/wailsapp/wails/v2/cmd/wails@${{ env.WAILS_VERSION }} + go install github.com/CycloneDX/cyclonedx-gomod/cmd/cyclonedx-gomod@${{ env.CYCLONEDX_GOMOD_VERSION }} - name: Frontend deps working-directory: frontend run: npm ci @@ -39,16 +45,55 @@ jobs: $ver = "${{ github.ref_name }}" $date = (Get-Date -Format "yyyy-MM-dd") wails build -platform windows/amd64 -ldflags "-X main.Version=$ver -X main.Commit=${{ github.sha }} -X main.BuildDate=$date" - - name: Checksums + - name: Generate CycloneDX SBOMs + shell: pwsh + run: | + cyclonedx-gomod mod -json -output build/bin/backend.cdx.json + Push-Location frontend + npm sbom --omit=dev --sbom-format=cyclonedx | Out-File ../build/bin/frontend.cdx.json -Encoding utf8 + Pop-Location + - name: Write release manifest shell: pwsh run: | + $manifest = [ordered]@{ + version = "${{ github.ref_name }}" + commit = "${{ github.sha }}" + buildDate = (Get-Date -Format "yyyy-MM-dd") + goVersion = "${{ env.GO_VERSION }}" + nodeVersion = "${{ env.NODE_VERSION }}" + wailsVersion = "${{ env.WAILS_VERSION }}" + signed = [bool]($env:WINDOWS_CERT_PFX_BASE64) + } + $manifest | ConvertTo-Json | Out-File build/bin/release-manifest.json -Encoding utf8 + - name: Sign executable (optional) + if: env.WINDOWS_CERT_PFX_BASE64 != '' + shell: pwsh + run: | + $certPath = Join-Path $env:RUNNER_TEMP "mailmerge-go-signing.pfx" + [IO.File]::WriteAllBytes($certPath, [Convert]::FromBase64String($env:WINDOWS_CERT_PFX_BASE64)) + $signTool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin" -Filter signtool.exe -Recurse | + Sort-Object FullName -Descending | + Select-Object -First 1 + if (-not $signTool) { throw "signtool.exe was not found on the runner" } Get-ChildItem build/bin/*.exe | ForEach-Object { - (Get-FileHash $_.FullName -Algorithm SHA256).Hash + " " + $_.Name - } | Out-File build/bin/SHA256SUMS.txt -Encoding ascii + & $signTool.FullName sign /fd SHA256 /f $certPath /p $env:WINDOWS_CERT_PASSWORD /tr http://timestamp.digicert.com /td SHA256 $_.FullName + if ($LASTEXITCODE -ne 0) { throw "Authenticode signing failed for $($_.Name)" } + } + Remove-Item $certPath -Force + - name: Checksums + shell: pwsh + run: | + Get-ChildItem build/bin/* -File | + Where-Object { $_.Name -ne "SHA256SUMS.txt" } | + Sort-Object Name | + ForEach-Object { + (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + " " + $_.Name + } | Out-File build/bin/SHA256SUMS.txt -Encoding ascii - name: Publish release uses: softprops/action-gh-release@v2 with: files: | build/bin/*.exe + build/bin/*.json build/bin/SHA256SUMS.txt generate_release_notes: true diff --git a/.golangci.yml b/.golangci.yml index a673625..1ffed0d 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,26 +1,35 @@ -# golangci-lint configuration for MailMerge-Go. +# golangci-lint v2 configuration for MailMerge-Go. +version: "2" + run: - timeout: 5m tests: true linters: + default: none enable: - - govet - - staticcheck - errcheck + - govet - ineffassign - - unused - - gofmt - misspell + - staticcheck - unconvert + - unused + exclusions: + rules: + # COM automation deliberately ignores selected cleanup errors after the + # primary operation has already completed or failed. + - path: backend/outlook/ + linters: + - errcheck + # Test helpers intentionally ignore some best-effort temp-file cleanup. + - path: _test\.go + linters: + - errcheck + +formatters: + enable: + - gofmt issues: - exclude-rules: - # COM automation and Wails event emits legitimately ignore some errors. - - path: backend/outlook/ - linters: [errcheck] - # Test helpers frequently ignore write errors on temp files. - - path: _test\.go - linters: [errcheck] max-issues-per-linter: 0 max-same-issues: 0 diff --git a/CHANGELOG.md b/CHANGELOG.md index c16f2f1..96ca156 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,48 +4,94 @@ All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/), and the project aims to follow semantic versioning. -## [Unreleased] — Remediation +## [Unreleased] — Full remediation and release hardening ### Added -- Canonical merge-field system (`backend/mergefield`): `{{field_id}}` and legacy - `{Field}` syntax, `{{field|fallback}}`, collision detection, Unicode/space/hyphen - support, structured diagnostics. -- `net/mail`-based email validation and address-list parsing (`backend/email`), - with an explicit duplicate-resolution policy. -- Campaign engine (`backend/campaign`): `EmailSender` interface, deterministic - `FakeSender`, single `Renderer`, full preflight, context-cancellable runner with - an injectable clock, typed `CampaignResult` with per-recipient attempt history, - validated `SendOptions`. -- Classic-Outlook COM sender on a dedicated STA worker thread (`backend/outlook`), - with a non-Windows stub and actionable capability detection. -- HTML sanitization (bluemonday) + email normalization (`backend/htmlutil`); - DOMPurify-sanitized, sandboxed previews. -- Local suppression list and campaign history (repository interface + JSON store). -- Atomic, validated persistence for settings and templates; settings schema - versioning/migration. -- Contact import improvements: UTF-8 BOM handling, stable contact IDs, duplicate - header detection, blank-row skipping, size/row/column limits. -- Frontend: preflight-gated sending, Cancel, backend-driven previews, stable-ID - selection, attachment size wiring, accessibility labels, Vitest tests. -- GitHub Actions CI + tag-based release; golangci-lint config; reproducible - version stamping (ldflags); `npm ci` in build. -- Documentation: architecture, merge fields, campaign lifecycle, Outlook - compatibility, testing, security, Graph sender design; CONTRIBUTING/SECURITY. + +- Canonical merge-field support through `backend/mergefield`, including + `{{field_id}}`, `{{field|fallback}}`, legacy `{Field}` compatibility, collision + detection, structured diagnostics, and support for Unicode and punctuated column + names. +- `net/mail`-based email validation, address-list parsing, and explicit duplicate + policy handling through `backend/email`. +- A platform-independent campaign engine with a single renderer, full preflight, + cancellation, validated send options, typed campaign states, per-recipient + attempt history, and deterministic fake-sender tests. +- A classic-Outlook COM sender running on a dedicated STA worker, with actionable + readiness reporting and a non-Windows stub. +- HTML normalization and sanitization, plus DOMPurify-sanitized previews in a + sandboxed iframe. +- Local suppression-list and campaign-history repositories. +- Persisted immutable campaign snapshots with durable campaign IDs, parent/child + retry lineage, run numbers, and retry-after-restart support. +- Campaign History UI with refresh, retry-failed, delete-one, and clear-all actions. +- An accessible test-send workflow with independent destination and merge-data + contact, optional `{{email}}` replacement, rendered preview, and Outlook Drafts + mode. +- Structured preflight review showing recipients, warnings, blocking errors, + estimated duration, attachment size, and delivery mode. +- Draft-only delivery for test messages and full campaigns. +- An application error boundary and Windows-aware attachment-path deduplication. +- Backend and frontend CI coverage artifacts. +- CycloneDX SBOM generation for Go and frontend production dependencies. +- A manual Release Candidate workflow and documented release go/no-go checklist. +- Tagged-release manifests, comprehensive SHA-256 checksums, and optional + Authenticode signing hooks driven by repository secrets. +- Windows root-package tests covering persisted retries, immutable snapshots, + draft-mode persistence, and headless campaign execution. +- Architecture, merge-field, campaign-lifecycle, Outlook compatibility, testing, + security, release-evidence, and future Graph-sender documentation. ### Changed -- Aligned the Wails module with the pinned v2.11.0 CLI, switched Wails frontend - installs to `npm ci`, and added Windows, Linux, and macOS build entry points. -- Upgraded the frontend build/test toolchain to Vite 8, Vitest 4, and TypeScript - 6; Wails timestamp bindings now expose RFC 3339 JSON timestamps as TypeScript - strings. -- Updated current documentation and active code comments to describe the - canonical merge pipeline, supported build commands, and current toolchain. -- `SendBulkEmails` returns a typed `CampaignResult` (was an untyped summary); - Outlook COM removed from `backend/services`. -- Removed the hard-coded 500 ms send delay (now sourced from settings). -- README corrected: no unqualified "production-ready" claim, Go 1.24, New Outlook - is unsupported for sending. + +- `SendBulkEmails` now returns a typed `CampaignResult`; Outlook COM is isolated + from application services behind `campaign.EmailSender`. +- Preview, test send, and campaign execution use the same backend renderer. +- Campaign result semantics distinguish completed, completed-with-failures, + stopped-on-failure, cancelled, preflight-failed, and runtime-failed outcomes. +- Retry selects only recipients whose latest attempt failed and performs fresh + preflight against the current environment. +- Campaign history now performs legacy-record normalization and atomic durable + writes. +- Settings and templates use validated atomic persistence with schema migration. +- Contact import handles UTF-8 BOMs, stable contact IDs, duplicate headers, blank + rows, and file/row/column limits. +- Outlook COM startup, error classification, capability reporting, cancellation, + and worker shutdown are hardened. +- Unsupported sender-account and shared-mailbox selection are no longer advertised. +- Browser prompt/confirm send flows were replaced with accessible application + dialogs. +- Frontend sending uses persisted campaign IDs for restart-safe retry. +- Attachment metadata is cached per resolved path during rendering and preflight. +- Send pacing is sourced from validated settings instead of a hard-coded delay. +- The frontend toolchain was upgraded to Vite 8, Vitest 4, and TypeScript 6. +- Wails timestamp bindings expose RFC 3339 JSON timestamps as TypeScript strings. +- Wails frontend installs use `npm ci`, the lockfile is synchronized, and Node + 22.23.1 is pinned across CI and release workflows. +- golangci-lint was migrated to the v2 configuration and action line. +- `govulncheck` and production npm audit are blocking release gates. +- Linux, macOS, and Windows build entry points are documented consistently while + Outlook sending remains Windows-only. +- README and contributor documentation now describe campaign history, draft mode, + local-data retention, CI evidence, and the distinction between merge readiness + and manual release validation. + +### Security + +- Imported values are escaped before insertion into HTML messages, and authored + HTML is sanitized before preview and send. +- Dependency scans fail CI on reachable Go vulnerabilities or high-severity + production npm findings. +- Template and campaign identifiers are validated against path traversal. +- Release artifacts include CycloneDX SBOMs, a release manifest, and SHA-256 + checksums. +- Authenticode signing is optional and uses repository secrets; certificate and + password material is never stored in the repository. ### Removed -- Unused `@tanstack/react-table` frontend dependency. -- Hard-coded `1.3.0` / `Your Name` app metadata (now build-time version vars). + +- The unused `@tanstack/react-table` frontend dependency. +- The hard-coded 500 ms send delay. +- Hard-coded application version and author metadata in favor of build-time values. +- Unqualified production-readiness claims and unsupported New Outlook capability + claims. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 047580c..bea5c92 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,49 +5,71 @@ Thanks for your interest in improving MailMerge Go. ## Prerequisites - Go 1.24+ -- Node.js 20.19+ or 22.12+ +- Node.js 22.23.1 (the version pinned in CI and release workflows) - Wails CLI v2.11.0 (`go install github.com/wailsapp/wails/v2/cmd/wails@v2.11.0`) +- golangci-lint v2.11.4 when running lint locally ## Development ```powershell -wails dev # run the app with hot reload +wails dev ``` ## Before opening a pull request -Run the same checks CI runs: +Run the same core checks CI runs: ```powershell gofmt -w . go vet ./backend/... -go test -race ./backend/... +go test -race -covermode=atomic -coverprofile=backend-coverage.out ./backend/... +golangci-lint run ./backend/... cd frontend npm ci -npm run test +npm run test -- --coverage npm run build +npm audit --omit=dev --audit-level=high +``` + +Go vulnerability check: + +```powershell +go install golang.org/x/vuln/cmd/govulncheck@latest +govulncheck ./backend/... ``` To verify the packaged Windows application locally, run -`./scripts/build-windows.ps1`. The Linux and macOS scripts are intended to run -on their respective native hosts. +`./scripts/build-windows.ps1`, then run `go test .` on Windows to compile and test +the root Wails-bound application APIs. Linux and macOS scripts are intended for +their respective native hosts. + +## Release candidates -Optionally: `golangci-lint run ./backend/...`. +Maintainers should run the **Release Candidate** GitHub Actions workflow before +creating a version tag. It produces coverage evidence, vulnerability-scan results, +CycloneDX SBOMs, a Windows executable, and checksums without publishing a release. +Follow [`dev_docs/release-checklist.md`](dev_docs/release-checklist.md). ## Conventions - Keep Outlook COM / `go-ole` confined to `backend/outlook`. The campaign engine - and everything above must depend only on `campaign.EmailSender`. -- Preview, test send, and bulk send must go through the single - `campaign.Renderer` — do not add ad-hoc frontend merge substitutions. -- Persisted files must be written atomically and validated on load. -- Add or update tests for behavior changes; the campaign engine must stay testable - without Outlook (use `campaign.FakeSender`). -- Regenerate Wails bindings after changing bound Go signatures: - `wails generate module`. + and everything above it must depend only on `campaign.EmailSender`. +- Preview, test send, and bulk send must use the single `campaign.Renderer`; do not + add ad-hoc frontend merge substitutions. +- Persisted campaign runs are immutable snapshots. Retries create linked child runs + rather than rewriting the original result. +- Persisted files must be written atomically and validated/normalized on load. +- Add or update tests for behavior changes; the campaign engine must remain testable + without Outlook by using `campaign.FakeSender`. +- Regenerate Wails bindings after changing bound Go signatures with + `wails generate module`, and commit the generated bindings with the source change. +- Never commit contact data, generated campaign history, signing certificates, + signing passwords, access tokens, or other secrets. -## Commits & branches +## Commits and branches - Work on a feature branch; do not rewrite `main` history. - Prefer small, focused commits grouped by concern. +- Keep pull requests in draft while required CI checks are red. +- Do not merge by bypassing the required release and security gates. diff --git a/README.md b/README.md index ca62a70..9e2fbdc 100644 --- a/README.md +++ b/README.md @@ -4,319 +4,284 @@ # MailMerge Go -A Windows desktop application for Outlook-based email mail merge. Built with a Go -backend (classic Outlook COM automation) and a React/TypeScript frontend, bundled -with Wails v2. +MailMerge Go is a Windows desktop application for creating and running personalized email campaigns through classic Microsoft Outlook. It combines a Go backend, a React/TypeScript frontend, and Wails v2 in a local desktop application. -> **Outlook support:** classic desktop Outlook only. The **New Outlook** app does -> **not** expose COM automation and is not supported for sending. +> **Outlook support:** sending and draft creation require classic desktop Outlook with a configured mail account. New Outlook does not expose COM automation and is not supported. -## Features +## Key capabilities -### Contact Management -- **Contact Import**: Import contacts from CSV or Excel (.xlsx) files -- **Custom Merge Fields**: Use any imported column as a merge field -- **Contact Filtering and Selection**: Search contacts and select recipients by stable ID -- **Duplicate Handling**: Detect duplicate recipient addresses before sending and apply the configured policy -- **Recent Files**: Quick access to recently opened contact files +### Contact import and selection -### Email Composition -- **Rich Text Editor**: Compose emails in plain text or HTML with a WYSIWYG editor -- **Dynamic Merge Fields**: Merge fields auto-generated from your file columns -- **Email Preview**: Render a preview through the same backend renderer used for sending -- **Email Templates**: Save and load reusable templates, including built-in templates -- **CC/BCC Support**: Add static or personalized CC and BCC recipient lists +- Import contacts from CSV and Excel (`.xlsx`) files. +- Use any imported column as a merge field. +- Search, select, and exclude recipients by stable contact ID. +- Detect duplicate email addresses and apply the configured duplicate policy during preflight. +- Reopen recently used contact files. + +### Composition and preview + +- Compose plain-text or HTML messages. +- Use canonical merge fields such as `{{first_name}}`, `{{company}}`, and `{{account_manager}}`. +- Add fallback values, for example `{{first_name|there}}`. +- Continue using legacy single-brace fields such as `{FirstName}` for backward compatibility. +- Add static or personalized CC and BCC values. +- Preview the rendered subject and body for a selected contact through the same backend renderer used for sending. +- Save and reuse templates. ### Attachments -- **Multiple Attachments**: Add multiple file attachments to your emails -- **Drag & Drop**: Drag files directly onto the attachment area -- **Size Tracking**: View individual and total attachment sizes - -### Sending -- **Test Emails**: Send test emails before bulk sending -- **Real-time Progress**: Track sending progress with live updates -- **Retry Failed**: Retry only recipients whose latest attempt failed -- **Rate Limiting**: Configure validated inter-message and batch pacing -- **Export Logs**: Export send results to CSV for record-keeping -- **Outlook Integration**: Uses Microsoft Outlook COM automation for reliable email delivery - -### UI/UX -- **Dark/Light Mode**: Toggle between dark and light themes -- **Settings Panel**: Centralized settings with theme, delay, and notification options -- **Keyboard Shortcuts**: Power user shortcuts for common actions -- **Responsive Design**: Clean, modern interface that works on any screen size - -### Keyboard Shortcuts -- `Ctrl+O` - Open contact file -- `Ctrl+S` - Save current email as template -- `Ctrl+Enter` - Send test email -- `Ctrl+Shift+Enter` - Send to all selected contacts -- `Ctrl+,` - Open settings -- `Ctrl+D` - Toggle dark/light mode -- `Escape` - Close any open modal + +- Add multiple attachments through the file picker or drag and drop. +- Review individual and combined attachment sizes. +- Deduplicate equivalent Windows paths case-insensitively. +- Resolve and cache attachment metadata during preflight and rendering. + +### Safe campaign execution + +- Review a structured preflight summary before sending. +- See blocking errors, warnings, recipient count, attachment size, estimated duration, and delivery mode. +- Send a representative test message using one contact’s merge data and an independently selected test destination. +- Save test messages or full campaigns to Outlook Drafts instead of submitting them. +- Track live campaign progress and cancel an active run. +- Distinguish completed, partially failed, stopped, cancelled, preflight-failed, and runtime-failed outcomes. +- Export campaign results to CSV. + +### Durable campaign history and retry + +- Persist an immutable snapshot of each campaign run locally. +- Review prior runs from the Campaign History screen. +- Retry only recipients whose latest attempt failed. +- Re-run failed recipients after restarting the application. +- Preserve attempt history, parent/child retry lineage, and incrementing run numbers. +- Delete individual history records or clear all campaign history. ## Requirements -### For Users -- Windows 10 or Windows 11 -- Microsoft Outlook installed and configured with an email account +### End users -### For Developers -- Go 1.24 or later (matches `go.mod`; CI pins 1.24.x) -- Node.js 20.19+ or 22.12+ (required by Vite 8) -- Wails CLI v2.11.0 +- Windows 10 or Windows 11. +- Classic Microsoft Outlook installed and configured with at least one mail account. -## Installation +### Developers -### Pre-built Binary +- Go 1.24 or later. CI uses Go 1.24.x. +- Node.js 22.23.1, matching CI and release workflows. +- Wails CLI v2.11.0. +- golangci-lint v2.11.4 for local lint parity. -Download the latest release from the [Releases](../../releases) page and run `MailMergeApp.exe`. +## Installation -### Build from Source +### Prebuilt release -1. Install [Go](https://go.dev/dl/) -2. Install [Node.js](https://nodejs.org/) -3. Install Wails CLI: - ```powershell - go install github.com/wailsapp/wails/v2/cmd/wails@v2.11.0 - ``` +Download the latest published build from the [Releases](../../releases) page. Review the release notes and SHA-256 checksums before running the executable. -4. Clone the repository and build: - ```powershell - cd MailMerge-Go - .\scripts\build-windows.ps1 - ``` +### Build from source -5. The executable will be at `build/bin/MailMergeApp.exe` +```powershell +# Install the pinned Wails CLI +go install github.com/wailsapp/wails/v2/cmd/wails@v2.11.0 + +# Clone the repository, enter it, and build +cd MailMerge-Go +.\scripts\build-windows.ps1 +``` -## Build Scripts +The executable is written to `build/bin/MailMergeApp.exe`. -The supported Windows entry point is `scripts/build-windows.ps1`: +Supported Windows build options: ```powershell .\scripts\build-windows.ps1 # Production build .\scripts\build-windows.ps1 -Clean # Clean build output first -.\scripts\build-windows.ps1 -Debug # Build with debug symbols +.\scripts\build-windows.ps1 -Debug # Include debug symbols .\scripts\build-windows.ps1 -SkipFrontendInstall # Reuse installed dependencies ``` -`scripts/build-linux.sh` and `scripts/build-macos.sh` provide corresponding -Wails targets. Run them on their native platforms with the Wails system -dependencies installed. They build the desktop shell only; sending still requires -classic Outlook on Windows. +Linux and macOS build scripts create the desktop shell on their native platforms, but Outlook sending remains Windows-only. ## Development -Run in development mode with hot-reload: +Run the application with frontend hot reload: + ```powershell wails dev ``` -This will run a Vite development server with fast hot reload for frontend changes. -A dev server also runs on http://localhost:34115 for browser-based debugging with access to Go methods. +The Vite development server is available at `http://localhost:34115` for browser-based debugging with access to bound Go methods. -## Usage +## Using MailMerge Go -### 1. Import Contacts +### 1. Import contacts -Click "Select Contact List" and choose a CSV or Excel file. Required columns: -- `Email` (required) - Must contain valid email addresses +Choose **Select Contact List** and open a CSV or Excel file. An `Email` column is required. `FirstName` and `LastName` are optional but recommended. Additional columns become merge fields. -Optional but recommended columns: -- `FirstName` - Used for personalization -- `LastName` - Used for personalization +Column matching is case-insensitive. A sample file is available at `samples/contacts.csv`. -**Any additional columns** in your file become available as merge fields. For -example, a `Company` column becomes `{{company}}`. +### 2. Compose the message -Column names are case-insensitive. Sample file included in `samples/contacts.csv`. +Enter the subject and body, choose plain-text or HTML mode, and insert merge fields generated from the imported columns. -After import: -- **Duplicates**: Duplicate recipient groups are surfaced in preflight and resolved by the configured policy before sending -- **Selection**: All contacts are selected by default. Use checkboxes to select/deselect -- **Search**: Use the search box to filter contacts by name or email +Canonical merge syntax is documented in [`dev_docs/merge-fields.md`](dev_docs/merge-fields.md). Unknown fields block sending instead of silently rendering as empty. Values inserted into HTML messages are escaped, and authored HTML is sanitized. -### 2. Compose Email +### 3. Preview and attach files -Enter your email subject and body. Merge field buttons are automatically generated based on your imported file columns. +Preview the message for a specific contact. Add any attachments and review the total size before continuing. -**Merge field syntax** (see [dev_docs/merge-fields.md](dev_docs/merge-fields.md)): -- Canonical: `{{field_id}}` — e.g. `{{first_name}}`, `{{account_manager}}` -- With fallback: `{{first_name|there}}` — uses the fallback when the value is blank -- Legacy single-brace `{FirstName}` is still supported for backward compatibility +### 4. Run a test -Columns with spaces, hyphens, punctuation, or non-ASCII characters are supported -(e.g. `Account Manager` → `{{account_manager}}`). Unknown fields are reported and -**block sending** rather than silently rendering as empty. Merge values are -HTML-escaped in HTML emails. +Open **Send Test Email**, choose the contact that supplies merge data, enter the destination address, and select either immediate sending or Outlook Drafts. The test dialog displays the rendered subject and body before execution. -**Common fields**: `{{first_name}}`, `{{last_name}}`, `{{email}}`, plus any column -from your file. +### 5. Review preflight and run the campaign -Toggle between plain text and rich text (HTML) mode. +Choose the selected recipients and delivery mode, then review the structured preflight dialog. Sending remains blocked while preflight contains a blocking issue. -**Preview**: Click "Preview Email" to see exactly how your email will look for any specific contact. +### 6. Review history and retry failures -### 3. Add Attachments (Optional) +Open **Campaign History** to inspect prior runs. Failed recipients can be retried from the persisted campaign snapshot, including after the application has been restarted. -Click "Add Attachments" to attach files, or **drag and drop** files directly onto the attachment area. File sizes are displayed, and you'll see a warning if total size exceeds 20MB. +## Keyboard shortcuts -### 4. Send Test Email +- `Ctrl+O` — open a contact file. +- `Ctrl+S` — save the current message as a template. +- `Ctrl+Enter` — open the test-send workflow. +- `Ctrl+Shift+Enter` — start the selected-recipient workflow. +- `Ctrl+,` — open settings. +- `Ctrl+D` — toggle light and dark mode. +- `Escape` — close the active modal. -Before sending to all recipients, click "Send Test Email" to verify your template looks correct. +## Local data and privacy -### 5. Send to Selected +Application data is stored locally under `%AppData%\MailMergeGo`: -Click "Send to Selected" to begin sending to all selected contacts. Progress will be displayed in real-time. +- `settings.json` — application settings. +- `templates/*.json` — saved templates. +- `suppression.json` — local do-not-send entries. +- `campaigns/*.json` — immutable campaign snapshots and results. -### 6. Review Results & Retry +Campaign history can contain recipient data, templates, attachment paths, and result details needed for restart-safe retry. History is retained until the user deletes individual records or clears it; automatic retention is not currently implemented. -After sending completes: -- View success/failure summary -- **Retry Failed**: If any emails failed, click "Retry Failed" to attempt them again -- Export the results log to CSV for your records +MailMerge Go does not send contact data, credentials, or message content to an external service. Messages are submitted through the user’s local Outlook profile. The application does not add tracking pixels or unsubscribe links. -## Project Structure - -``` -MailMergeApp/ -├── app.go # Wails bindings (thin controller over the engine) -├── main.go # Application entry point + build-time version vars -├── backend/ -│ ├── models/ # Shared data structs (Contact, Settings, Template, ...) -│ ├── mergefield/ # Canonical merge-field model + renderer + diagnostics -│ ├── email/ # net/mail validation, address lists, duplicate policy -│ ├── htmlutil/ # HTML normalization + sanitizer (bluemonday) -│ ├── campaign/ # EmailSender iface, FakeSender, preflight, runner, results -│ ├── outlook/ # Classic-Outlook COM sender (dedicated STA worker) + stub -│ ├── graph/ # Microsoft Graph sender (design-stage stub) -│ ├── storage/ # Campaign history repository (JSON impl) -│ └── services/ # File import, templates, settings, suppression persistence -├── frontend/ -│ ├── src/ -│ │ ├── App.tsx # Main React component -│ │ ├── components/ # UI components (+ *.test.tsx) -│ │ ├── styles/ # CSS -│ │ └── types/ # TypeScript types -│ └── wailsjs/ # Generated Wails bindings -├── .github/workflows/ # CI + release pipelines -├── scripts/ # Native-platform Wails build entry points -├── dev_docs/ # Architecture, merge fields, testing, security, ... -├── samples/contacts.csv # Sample contact file -└── build/bin/ # Built executables -``` +See [`dev_docs/security.md`](dev_docs/security.md) for the security model and [`dev_docs/release-checklist.md`](dev_docs/release-checklist.md) for release validation requirements. ## Testing +Backend and root application checks: + ```powershell -# Go: unit tests for the campaign engine, merge fields, email, services, storage -go test ./backend/... +gofmt -w . go vet ./backend/... +go test -race -covermode=atomic -coverprofile=backend-coverage.out ./backend/... +golangci-lint run ./backend/... + +# Run on Windows to compile and test Wails-bound application APIs +go test . +``` + +Frontend checks: -# Frontend: type check, unit tests, production build +```powershell cd frontend npm ci -npm run test +npm run test -- --coverage npm run build +npm audit --omit=dev --audit-level=high ``` -Outlook COM is isolated behind an `EmailSender` interface, so the campaign engine -is fully testable without Outlook installed. Windows/Outlook integration tests are -opt-in behind a build tag: +Reachable Go vulnerability scan: ```powershell -go test -tags outlookintegration ./backend/outlook/... +go install golang.org/x/vuln/cmd/govulncheck@latest +govulncheck ./backend/... ``` -See [dev_docs/testing.md](dev_docs/testing.md). +Outlook COM is isolated behind `campaign.EmailSender`, allowing campaign tests to use `campaign.FakeSender` without Outlook. Opt-in Outlook integration tests use the `outlookintegration` build tag: -## Continuous Integration - -GitHub Actions (`.github/workflows/ci.yml`) runs on every pull request and push to -`main`: gofmt check, `go vet`, `go test -race`, golangci-lint, frontend -test/build (plus lint when configured), a Wails Windows build with an uploaded unsigned artifact, and -dependency scanning (govulncheck + npm audit). Releases are cut only from version -tags via `release.yml`. +```powershell +go test -tags outlookintegration ./backend/outlook/... +``` -## Data Storage & Privacy +See [`dev_docs/testing.md`](dev_docs/testing.md) for more detail. -Application data is stored locally under your Windows user profile -(`%AppData%\MailMergeGo`): +## CI and releases -- `settings.json` — application settings -- `templates/*.json` — saved email templates -- `suppression.json` — local do-not-send list -- `campaigns/*.json` — campaign run history +Every pull request runs five permanent CI jobs: -No contact data, credentials, or email content is sent anywhere by this -application; email is submitted through your local Outlook profile. No tracking -pixels or unsubscribe links are added to messages. See -[dev_docs/security.md](dev_docs/security.md). +1. Go formatting, vet, race tests with coverage, and golangci-lint. +2. Frontend installation, tests with coverage, and production build. +3. Reachable Go vulnerability scanning and production npm audit. +4. Go and frontend CycloneDX SBOM generation. +5. Windows Wails build, root-package tests, and unsigned executable artifact upload. -## Security Notes +Maintainers should run the manual **Release Candidate** workflow against the intended commit before creating a version tag. Tagged releases generate the executable, release manifest, CycloneDX SBOMs, and SHA-256 checksums, with optional Authenticode signing when repository secrets are configured. -- Imported contact values are HTML-escaped before being placed into HTML emails, - and authored HTML bodies are sanitized (scripts, event handlers, unsafe URLs, - iframes, and embedded objects are removed). -- Previews are additionally sanitized and rendered in a sandboxed iframe. -- Template IDs are backend-generated and cannot traverse directories; built-in - templates cannot be overwritten. -- Settings, templates, suppression list, and campaign history are written - atomically (temp file + rename). -- "Submitted" reflects acceptance by Outlook for sending; it does not confirm - delivery. +Release evidence is described in [`dev_docs/release-evidence.md`](dev_docs/release-evidence.md). -## Troubleshooting +## Project structure -### "Outlook is not available" +```text +MailMerge-Go/ +├── app.go # Wails-bound application controller +├── app_test.go # Root application persistence/retry tests +├── main.go # Application entry point and build metadata +├── backend/ +│ ├── campaign/ # Renderer, preflight, runner, results, sender interface +│ ├── email/ # Address validation and duplicate policy +│ ├── graph/ # Future Microsoft Graph sender design stub +│ ├── htmlutil/ # HTML normalization and sanitization +│ ├── mergefield/ # Canonical merge-field schema and rendering +│ ├── models/ # Shared application models +│ ├── outlook/ # Classic Outlook COM sender and non-Windows stub +│ ├── services/ # Import, templates, settings, and suppression +│ └── storage/ # Campaign-history repository and JSON store +├── frontend/src/ +│ ├── components/ # Workflow dialogs and reusable UI +│ ├── styles/ # Application styling +│ └── App.tsx # Main workflow composition +├── .github/workflows/ # CI, release-candidate, and tagged-release workflows +├── dev_docs/ # Architecture, testing, security, and release documentation +├── scripts/ # Native-platform build entry points +└── samples/contacts.csv # Sample contact list +``` -- Ensure Microsoft Outlook is installed (not the web version) -- Make sure you have configured an email account in Outlook -- Try opening Outlook manually first and ensure it's fully loaded -- Close any Outlook security dialogs that may be blocking +## Troubleshooting -### "Failed to send email" or "Interface marshalled for different thread" +### Outlook is unavailable -This COM threading error has been fixed in the latest version. If you encounter it: -- Ensure you're running the latest build -- The application now properly locks OS threads for COM operations -- Verify the configured sending delay and any Outlook security prompts before retrying +- Confirm classic desktop Outlook is installed, not New Outlook or Outlook on the web. +- Configure at least one mail account and verify Outlook can send manually. +- Open Outlook and resolve any security, sign-in, or modal dialogs. +- Restart Outlook and MailMerge Go if COM automation remains unavailable. -### "Failed to create mail item" +### A campaign fails or stops -- Check that Outlook is not showing any dialogs or security prompts -- Verify your email account is properly configured and can send emails manually -- Check if your IT department has security policies blocking COM automation -- Try restarting Outlook and the application +- Review the structured campaign result and recipient attempt details. +- Confirm attachment paths still exist and are readable. +- Check Outlook security prompts and organizational policies. +- Retry failed recipients from Campaign History after correcting the underlying issue. -### Attachments not working +### A contact file does not parse -- Verify the file paths are correct and files exist -- Ensure files are not locked by another application -- Check that you have read permissions on the files +- Confirm the first nonblank row contains headers. +- Include an `Email` column. +- Check for duplicate headers or unsupported file size and row/column limits. +- Close the file in other applications and try again. -### Contact file not parsing correctly +## Known limitations -- Ensure your file has a header row -- Required column: `Email` (case-insensitive) -- Optional columns: `FirstName`, `LastName` (or `First Name`, `first_name`) -- Verify the file is not open in another application +- Sending and draft creation require classic Outlook COM automation on Windows. +- Sender-account selection and shared-mailbox selection are not currently supported or advertised. +- Microsoft Graph delivery is deferred to a future release. +- Campaign-history retention is user-managed; automatic retention policies are not yet implemented. ## License -MIT License - -## Credits - -Built with: -- [Wails](https://wails.io/) - Desktop application framework -- [go-ole](https://github.com/go-ole/go-ole) - COM bindings for Go -- [excelize](https://github.com/xuri/excelize) - Excel file processing -- [React](https://react.dev/) - UI framework -- [Lucide](https://lucide.dev/) - Icons -- [React Quill](https://github.com/zenoamaro/react-quill) - Rich text editor +MIT License. -## Configuration +## Built with -You can configure the project by editing `wails.json`. More information about the project settings can be found -in the [Wails Documentation](https://wails.io/docs/reference/project-config). +- [Wails](https://wails.io/) +- [go-ole](https://github.com/go-ole/go-ole) +- [excelize](https://github.com/xuri/excelize) +- [React](https://react.dev/) +- [Lucide](https://lucide.dev/) +- [React Quill](https://github.com/zenoamaro/react-quill) diff --git a/REMEDIATION_STATUS.md b/REMEDIATION_STATUS.md new file mode 100644 index 0000000..f91c012 --- /dev/null +++ b/REMEDIATION_STATUS.md @@ -0,0 +1,173 @@ +# MailMerge-Go Remediation Status + +Last updated: 2026-07-21 + +Implementation branch: `agent/post-pr-full-remediation` +Integration PR: #2 — Post-PR full remediation: stabilization and campaign safety + +## Readiness summary + +The remediation branch has a green automated integration baseline across five permanent CI jobs: + +1. Go formatting, vet, race tests with coverage, and golangci-lint. +2. Frontend installation, tests with coverage, and production build. +3. Reachable Go vulnerability scanning and production npm audit. +4. Go and frontend CycloneDX SBOM generation. +5. Windows Wails compilation, root-package tests, and executable artifact upload. + +The implementation is ready to enter `main` after the final documentation head passes those same jobs. Publishing a version tag remains separately gated by the clean-Windows/classic-Outlook checklist in `dev_docs/release-checklist.md`. + +## Phase status + +### Phase 0 — Stabilize Main and Fix CI + +**COMPLETE** + +- Pinned Node 22.23.1 across CI and release workflows. +- Synchronized the frontend lockfile. +- Migrated golangci-lint to v2. +- Made tests, lint, production builds, vulnerability scans, npm audit, SBOM generation, and Windows packaging real gates. +- Documented recommended branch-protection checks. + +Operational follow-up: configure the documented protections for `main` in repository settings. + +### Phase 1 — Campaign Reliability and Retry Safety + +**COMPLETE** + +- Retry selects only recipients whose latest attempt failed. +- Retry performs fresh preflight against the current sender, suppression list, templates, addresses, attachments, duplicate policy, and capabilities. +- Campaign states distinguish complete, partial, stopped, cancelled, preflight-failed, and runtime-failed outcomes. +- Attempt history, timing, campaign IDs, parent IDs, and run numbers are retained. +- Retry works from persisted history after application restart. +- Headless execution no longer requires a Wails runtime context. + +### Phase 2 — Outlook COM Reliability + +**IN PROGRESS — manual compatibility validation remains** + +Completed: + +- Hardened STA initialization, fatal/transient/recipient/cancelled error classification, cancellation, and worker shutdown. +- Corrected advertised capabilities. +- Added Outlook Drafts delivery for tests and campaigns. +- Isolated Wails progress emission from campaign execution. + +Remaining: + +- Complete the clean-Windows/classic-Outlook release matrix. +- Expand tests for additional HRESULT and shutdown edge cases. +- Sender-account selection remains deferred and unadvertised. + +### Phase 3 — Campaign Persistence and History + +**COMPLETE** + +- Stores immutable campaign inputs, result details, timing, delivery mode, and retry lineage. +- Uses validated atomic JSON persistence and normalizes legacy records. +- Isolates corrupt records during list operations. +- Supports history detail, retry, delete-one, and clear-all APIs. +- Covers restart-safe retry, immutable snapshots, draft persistence, corruption, traversal, and cleanup in tests. + +### Phase 4 — Product Workflow Completion + +**IN PROGRESS — release-critical workflows complete** + +Completed: + +- Accessible test-send dialog with independent destination and merge-data contact. +- Rendered test preview and send-or-draft selection. +- Structured preflight review. +- Campaign-history review, retry, delete-one, and clear-all. + +Future enhancements: + +- Manual duplicate resolution. +- Suppression-management UI. +- Contact editing, exclusion, and cleaned export. +- Multi-sheet Excel selection and column-mapping profiles. + +### Phase 5 — Frontend Maintainability + +**IN PROGRESS** + +Completed: + +- Removed browser prompt/critical confirm flows. +- Added an error boundary and fixed stale theme settings. +- Added typed Wails history bindings. +- Split test-send, preflight, history, and error-boundary concerns into components. + +Remaining: + +- Continue decomposing `App.tsx`. +- Normalize categorized Wails errors across all screens. + +### Phase 6 — Attachment Reliability + +**IN PROGRESS — release-critical handling complete** + +- Caches metadata per unique resolved path. +- Avoids repeated stats for static attachments. +- Deduplicates equivalent Windows paths. + +Remaining: packaged-app file-drop validation and personalized-path cache coverage. + +### Phase 7 — Import Scalability + +**IN PROGRESS** + +The current importer enforces limits and handles stable IDs, BOMs, duplicate headers, and blank rows. Dataset/schema modeling, multi-sheet mapping, virtualization, and explicit 10,000-contact validation remain future work. + +### Phase 8 — Security and Privacy + +**IN PROGRESS — automated security baseline complete** + +Completed: + +- Blocking Go vulnerability and production npm scans. +- HTML sanitization and escaped merge values. +- History deletion controls. +- SBOMs, release manifest, checksums, and optional secret-backed signing hooks. +- README disclosure of local campaign-history contents and user-managed retention. + +Remaining: expanded sanitizer vectors, log-redaction audit, configurable retention/export-before-delete, and suppression audit metadata. + +### Phase 9 — Tests + +**IN PROGRESS — release-critical baseline complete** + +- Backend race tests and frontend tests generate coverage artifacts. +- Added campaign state, retry, cancellation, persistence, attachment, draft, restart, snapshot, and headless-execution coverage. +- Windows CI builds the application and tests root Wails-bound APIs. + +Remaining: broader Outlook edge-case and frontend interaction coverage, plus reviewed coverage thresholds. + +### Phase 10 — CI/CD and Release Engineering + +**COMPLETE** + +- CI retains coverage, diagnostics, SBOMs, and a Windows executable. +- Release Candidate workflow validates without publishing. +- Tagged releases generate metadata, SBOMs, checksums, and optional Authenticode signing. +- Release checklist documents go/no-go, rollback, and required checks. + +### Phase 11 — Microsoft Graph Readiness + +**DEFERRED** + +The sender abstraction remains compatible with a future Graph implementation. Authentication, token storage, Graph send/draft behavior, shared mailboxes, throttling, tenant restrictions, and sender selection are outside this COM-focused release. + +### Phase 12 — Documentation + +**COMPLETE** + +- Maintained this phase-by-phase status record. +- Consolidated the changelog into one authoritative Unreleased section. +- Updated README coverage of preflight, draft mode, persisted history, restart-safe retry, privacy, testing, release evidence, and known limitations. +- Updated contributor, release-checklist, and release-evidence documentation. + +## Final decision + +**Merge readiness:** ready after the final documentation commit passes all five permanent CI jobs. +**Versioned release readiness:** manual Windows/classic-Outlook validation is still required before tagging or publishing a release. diff --git a/app.go b/app.go index 4fd20a2..bfccb82 100644 --- a/app.go +++ b/app.go @@ -47,10 +47,12 @@ type App struct { settingsService *services.SettingsService // Handles app settings persistence suppression *services.SuppressionService // Suppression / unsubscribe list history storage.CampaignRepository // Campaign run history (JSON-backed) + emitProgress func(models.ProgressUpdate) // Set only from the Wails lifecycle context - mu sync.Mutex // guards cancel and lastResult - cancel context.CancelFunc // cancels the in-flight campaign, if any - lastResult *campaign.CampaignResult + mu sync.Mutex // guards cancel and lastResult + cancel context.CancelFunc // cancels the in-flight campaign, if any + lastResult *campaign.CampaignResult + lastCampaignID string version string // build-time version metadata commit string @@ -99,24 +101,55 @@ func NewApp() *App { } } -// recordRun persists a terminal campaign result to history (best effort). -func (a *App) recordRun(subject string, isHTML bool, res campaign.CampaignResult) { +// persistRun stores an immutable campaign snapshot and returns the result with +// durable campaign lineage metadata. Persistence is best-effort: a send result is +// still returned when history storage is unavailable, but retry-by-ID will not be +// available for that run. +func (a *App) persistRun(c campaign.Campaign, res campaign.CampaignResult, parentID string, runNumber int) campaign.CampaignResult { if a.history == nil { - return + return res } + if runNumber < 1 { + runNumber = 1 + } + + id := uuid.NewString() + res.CampaignID = id + res.ParentCampaignID = parentID + res.RunNumber = runNumber + rec := storage.CampaignRecord{ - ID: uuid.NewString(), - StartedAt: time.Now(), - FinishedAt: time.Now(), - Subject: subject, - IsHTML: isHTML, - RecipientCount: res.Attempted + res.Skipped + res.Cancelled, - State: res.State, - Result: res, - } - if err := a.history.Save(rec); err != nil { + ID: id, + ParentCampaignID: parentID, + RunNumber: runNumber, + CreatedAt: time.Now(), + StartedAt: res.StartedAt, + FinishedAt: res.FinishedAt, + Duration: res.Duration, + Subject: c.SubjectTemplate, + SubjectTemplate: c.SubjectTemplate, + BodyTemplate: c.BodyTemplate, + IsHTML: c.IsHTML, + DraftOnly: c.DraftOnly, + Headers: cloneStrings(c.Headers), + Contacts: cloneContacts(c.Contacts), + Attachments: cloneStrings(c.Attachments), + CCTemplate: c.CCTemplate, + BCCTemplate: c.BCCTemplate, + DuplicatePolicy: c.DuplicatePolicy, + SendOptions: c.Options, + SenderType: string(campaign.StateClassicOutlook), + RecipientCount: len(c.Contacts), + State: res.State, + Result: res, + } + if err := a.history.Create(rec); err != nil { fmt.Printf("Warning: failed to record campaign run: %v\n", err) + res.CampaignID = "" + res.ParentCampaignID = "" + res.RunNumber = 0 } + return res } // GetCampaignHistory returns past campaign runs, newest first. @@ -131,10 +164,99 @@ func (a *App) GetCampaignHistory() []storage.CampaignRecord { return records } +// GetCampaign returns the immutable snapshot for one campaign run. +func (a *App) GetCampaign(id string) (*storage.CampaignRecord, error) { + if a.history == nil { + return nil, fmt.Errorf("campaign history is unavailable") + } + return a.history.Get(id) +} + +// DeleteCampaign deletes one campaign-history record. It never deletes linked +// parent or child runs implicitly. +func (a *App) DeleteCampaign(id string) error { + if a.history == nil { + return fmt.Errorf("campaign history is unavailable") + } + if err := a.history.Delete(id); err != nil { + return err + } + a.mu.Lock() + if a.lastCampaignID == id { + a.lastCampaignID = "" + a.lastResult = nil + } + a.mu.Unlock() + return nil +} + +// ClearCampaignHistory deletes every local campaign-history record. +func (a *App) ClearCampaignHistory() error { + if a.history == nil { + return fmt.Errorf("campaign history is unavailable") + } + records, err := a.history.List() + if err != nil { + return err + } + for _, rec := range records { + if err := a.history.Delete(rec.ID); err != nil { + return fmt.Errorf("delete campaign %s: %w", rec.ID, err) + } + } + a.mu.Lock() + a.lastCampaignID = "" + a.lastResult = nil + a.mu.Unlock() + return nil +} + +func (a *App) campaignFromRecord(rec storage.CampaignRecord) campaign.Campaign { + var suppressed map[string]bool + if a.suppression != nil { + suppressed = a.suppression.Set() + } + return campaign.Campaign{ + Headers: cloneStrings(rec.Headers), + Contacts: cloneContacts(rec.Contacts), + SubjectTemplate: rec.SubjectTemplate, + BodyTemplate: rec.BodyTemplate, + IsHTML: rec.IsHTML, + DraftOnly: rec.DraftOnly, + Attachments: cloneStrings(rec.Attachments), + CCTemplate: rec.CCTemplate, + BCCTemplate: rec.BCCTemplate, + Options: rec.SendOptions.Normalized(), + DuplicatePolicy: rec.DuplicatePolicy, + Suppressed: suppressed, + } +} + +func cloneStrings(in []string) []string { + return append([]string(nil), in...) +} + +func cloneContacts(in []models.Contact) []models.Contact { + out := make([]models.Contact, len(in)) + for i, contact := range in { + out[i] = contact + if contact.CustomFields != nil { + out[i].CustomFields = make(map[string]string, len(contact.CustomFields)) + for key, value := range contact.CustomFields { + out[i].CustomFields[key] = value + } + } + } + return out +} + // startup is called when the app starts. It receives the Wails context // which is used for runtime operations like dialogs and events. func (a *App) startup(ctx context.Context) { a.ctx = ctx + a.emitProgress = func(update models.ProgressUpdate) { + runtime.EventsEmit(ctx, "email:progress", update) + } } // shutdown releases the Outlook COM worker cleanly. @@ -336,6 +458,7 @@ func (a *App) SendTestEmail(request models.TestEmailRequest) error { SubjectTemplate: request.SubjectTemplate, BodyTemplate: request.BodyTemplate, IsHTML: request.IsHTML, + DraftOnly: request.DraftOnly, Attachments: request.Attachments, CCTemplate: firstNonEmpty(request.CCTemplate, request.CC), BCCTemplate: firstNonEmpty(request.BCCTemplate, request.BCC), @@ -372,20 +495,54 @@ func (a *App) SendBulkEmails(request models.EmailRequest) campaign.CampaignResul defer a.endRun(cancel) res := a.runner.Run(ctx, c, a.progressSink()) + res = a.persistRun(c, res, "", 1) + + a.mu.Lock() + a.lastResult = &res + a.lastCampaignID = res.CampaignID + a.mu.Unlock() + return res +} + +// RetryCampaign reconstructs a campaign from its persisted immutable snapshot, +// performs fresh preflight against the current environment, and persists the retry +// as a new child record. This remains safe after an application restart. +func (a *App) RetryCampaign(campaignID string) campaign.CampaignResult { + if a.history == nil { + return campaign.CampaignResult{State: campaign.CampaignPreflightFailed, FatalError: "campaign history is unavailable"} + } + rec, err := a.history.Get(campaignID) + if err != nil { + return campaign.CampaignResult{State: campaign.CampaignPreflightFailed, FatalError: fmt.Sprintf("load campaign %s: %v", campaignID, err)} + } + c := a.campaignFromRecord(*rec) + if len(c.Contacts) == 0 { + return campaign.CampaignResult{State: campaign.CampaignPreflightFailed, FatalError: "stored campaign does not contain recipient data"} + } + + ctx, cancel := a.beginRun() + defer a.endRun(cancel) + + res := a.runner.Retry(ctx, c, rec.Result, nil, a.progressSink()) + res = a.persistRun(c, res, rec.ID, rec.RunNumber+1) a.mu.Lock() a.lastResult = &res + a.lastCampaignID = res.CampaignID a.mu.Unlock() - a.recordRun(request.SubjectTemplate, request.IsHTML, res) return res } -// RetryFailed retries only the recipients whose latest attempt failed in the -// last campaign, appending new attempts without double-counting successes. +// RetryFailed is retained for Wails/API compatibility. New callers should use +// RetryCampaign with the CampaignID returned by SendBulkEmails. func (a *App) RetryFailed(request models.EmailRequest) campaign.CampaignResult { a.mu.Lock() + campaignID := a.lastCampaignID prev := a.lastResult a.mu.Unlock() + if campaignID != "" { + return a.RetryCampaign(campaignID) + } if prev == nil { return campaign.CampaignResult{State: campaign.CampaignPreflightFailed, FatalError: "no previous campaign to retry"} } @@ -395,7 +552,6 @@ func (a *App) RetryFailed(request models.EmailRequest) campaign.CampaignResult { defer a.endRun(cancel) res := a.runner.Retry(ctx, c, *prev, nil, a.progressSink()) - a.mu.Lock() a.lastResult = &res a.mu.Unlock() @@ -415,7 +571,11 @@ func (a *App) CancelCampaign() { // beginRun creates a cancelable context for a campaign and stores its cancel func. func (a *App) beginRun() (context.Context, context.CancelFunc) { - ctx, cancel := context.WithCancel(a.ctx) + base := a.ctx + if base == nil { + base = context.Background() + } + ctx, cancel := context.WithCancel(base) a.mu.Lock() a.cancel = cancel a.mu.Unlock() @@ -442,6 +602,7 @@ func (a *App) buildCampaign(request models.EmailRequest, applySuppression bool) SubjectTemplate: request.SubjectTemplate, BodyTemplate: request.BodyTemplate, IsHTML: request.IsHTML, + DraftOnly: request.DraftOnly, Attachments: request.Attachments, CCTemplate: firstNonEmpty(request.CCTemplate, request.CC), BCCTemplate: firstNonEmpty(request.BCCTemplate, request.BCC), @@ -471,14 +632,16 @@ func (a *App) duplicatePolicy() email.Policy { return email.DefaultPolicy } -// progressSink forwards campaign progress to the frontend's "email:progress" -// event channel (unchanged payload shape). +// progressSink forwards campaign progress through an emitter created only +// from the Wails lifecycle context. Headless execution and tests intentionally +// operate without an emitter. func (a *App) progressSink() campaign.ProgressSink { return campaign.ProgressFunc(func(p campaign.Progress) { - if a.ctx == nil { + emit := a.emitProgress + if emit == nil { return } - runtime.EventsEmit(a.ctx, "email:progress", models.ProgressUpdate{ + emit(models.ProgressUpdate{ Current: p.Current, Total: p.Total, Status: p.Status, diff --git a/app_test.go b/app_test.go new file mode 100644 index 0000000..57f255b --- /dev/null +++ b/app_test.go @@ -0,0 +1,118 @@ +package main + +import ( + "context" + "testing" + + "MailMergeApp/backend/campaign" + "MailMergeApp/backend/models" + "MailMergeApp/backend/storage" +) + +func TestRetryCampaignPersistsLineageAcrossRestart(t *testing.T) { + repo, err := storage.NewJSONCampaignRepository(t.TempDir()) + if err != nil { + t.Fatal(err) + } + + firstSender := campaign.NewFakeSender() + firstSender.FailFor = map[string]bool{"failed@example.com": true} + firstApp := &App{ + ctx: context.Background(), + sender: firstSender, + runner: campaign.NewRunner(firstSender), + history: repo, + } + request := models.EmailRequest{ + Contacts: []models.Contact{{ID: "contact-1", FirstName: "Ada", Email: "failed@example.com"}}, + SubjectTemplate: "Hello {{first_name}}", + BodyTemplate: "Body", + } + + initial := firstApp.SendBulkEmails(request) + if initial.CampaignID == "" { + t.Fatal("initial run did not receive a persisted campaign ID") + } + if initial.State != campaign.CampaignCompletedWithFailures || initial.Failed != 1 { + t.Fatalf("unexpected initial result: %+v", initial) + } + + // Simulate an application restart: create a new App with no in-memory lastResult. + retrySender := campaign.NewFakeSender() + restartedApp := &App{ + ctx: context.Background(), + sender: retrySender, + runner: campaign.NewRunner(retrySender), + history: repo, + } + retried := restartedApp.RetryCampaign(initial.CampaignID) + if retried.State != campaign.CampaignCompleted || retried.Submitted != 1 || retried.Failed != 0 { + t.Fatalf("unexpected retry result: %+v", retried) + } + if retried.ParentCampaignID != initial.CampaignID || retried.RunNumber != 2 || retried.CampaignID == "" { + t.Fatalf("retry lineage is incorrect: %+v", retried) + } + if len(retried.RecipientResults) != 1 || len(retried.RecipientResults[0].Attempts) != 2 { + t.Fatalf("retry did not preserve attempt history: %+v", retried.RecipientResults) + } + + records, err := repo.List() + if err != nil { + t.Fatal(err) + } + if len(records) != 2 { + t.Fatalf("expected original and retry records, got %d", len(records)) + } + child, err := repo.Get(retried.CampaignID) + if err != nil { + t.Fatal(err) + } + if child.ParentCampaignID != initial.CampaignID || child.RunNumber != 2 { + t.Fatalf("persisted child lineage is incorrect: %+v", child) + } + if child.SubjectTemplate != request.SubjectTemplate || len(child.Contacts) != 1 { + t.Fatalf("persisted snapshot is incomplete: %+v", child) + } +} + +func TestCampaignSnapshotIsIndependentFromRequestMutation(t *testing.T) { + repo, err := storage.NewJSONCampaignRepository(t.TempDir()) + if err != nil { + t.Fatal(err) + } + sender := campaign.NewFakeSender() + app := &App{ctx: context.Background(), sender: sender, runner: campaign.NewRunner(sender), history: repo} + request := models.EmailRequest{ + Contacts: []models.Contact{{ + ID: "contact-1", + Email: "a@example.com", + CustomFields: map[string]string{"company": "Original"}, + }}, + SubjectTemplate: "Hello", + BodyTemplate: "{{company}}", + } + result := app.SendBulkEmails(request) + request.Contacts[0].Email = "mutated@example.com" + request.Contacts[0].CustomFields["company"] = "Mutated" + + record, err := repo.Get(result.CampaignID) + if err != nil { + t.Fatal(err) + } + if record.Contacts[0].Email != "a@example.com" || record.Contacts[0].CustomFields["company"] != "Original" { + t.Fatalf("persisted snapshot changed with caller mutation: %+v", record.Contacts[0]) + } +} + +func TestHeadlessCampaignDoesNotRequireWailsRuntimeContext(t *testing.T) { + sender := campaign.NewFakeSender() + app := &App{sender: sender, runner: campaign.NewRunner(sender)} + result := app.SendBulkEmails(models.EmailRequest{ + Contacts: []models.Contact{{ID: "headless", Email: "headless@example.com"}}, + SubjectTemplate: "Headless", + BodyTemplate: "Body", + }) + if result.State != campaign.CampaignCompleted || result.Submitted != 1 { + t.Fatalf("unexpected headless campaign result: %+v", result) + } +} diff --git a/backend/campaign/fakesender.go b/backend/campaign/fakesender.go index 2529bd1..73e1e8a 100644 --- a/backend/campaign/fakesender.go +++ b/backend/campaign/fakesender.go @@ -12,14 +12,9 @@ import ( type FakeBehavior int const ( - // FakeAlwaysSucceed submits every message. FakeAlwaysSucceed FakeBehavior = iota - // FakeAlwaysFail fails every message (non-fatal). FakeAlwaysFail - // FakeFatal fails the first send with a fatal receipt (simulates Outlook - // dying / creation failure). FakeFatal - // FakeUnavailable reports the sender as unavailable in Preflight. FakeUnavailable ) @@ -30,24 +25,18 @@ type FakeSender struct { mu sync.Mutex Behavior FakeBehavior - // FailFor causes Send to fail for any recipient whose To contains one of - // these addresses (matched case-insensitively). - FailFor map[string]bool - // Delay is applied inside Send (respecting ctx) to simulate slow sends. - Delay time.Duration - // Caps overrides reported capabilities (defaults to full support). - Caps *SenderCapabilities + FailFor map[string]bool + Delay time.Duration + Caps *SenderCapabilities - Sent []RenderedMessage // messages submitted successfully - All []RenderedMessage // every message Send was called with + Sent []RenderedMessage + All []RenderedMessage sendCount int } -// NewFakeSender returns an always-succeeding fake sender. func NewFakeSender() *FakeSender { return &FakeSender{Behavior: FakeAlwaysSucceed} } -// Capabilities reports full capabilities unless overridden. func (f *FakeSender) Capabilities(context.Context) SenderCapabilities { if f.Caps != nil { return *f.Caps @@ -62,7 +51,6 @@ func (f *FakeSender) Capabilities(context.Context) SenderCapabilities { } } -// Preflight reports readiness based on Behavior. func (f *FakeSender) Preflight(context.Context) SenderStatus { if f.Behavior == FakeUnavailable { return SenderStatus{Available: false, State: StateUnavailable, Message: "fake sender is unavailable"} @@ -70,7 +58,6 @@ func (f *FakeSender) Preflight(context.Context) SenderStatus { return SenderStatus{Available: true, State: StateFake, Message: "fake sender ready"} } -// Send records and responds to a message. func (f *FakeSender) Send(ctx context.Context, msg RenderedMessage) SendReceipt { f.mu.Lock() f.All = append(f.All, msg) @@ -83,27 +70,27 @@ func (f *FakeSender) Send(ctx context.Context, msg RenderedMessage) SendReceipt defer t.Stop() select { case <-ctx.Done(): - return SendReceipt{Submitted: false, Err: ctx.Err()} + return SendReceipt{Submitted: false, Kind: SendErrorCancelled, Err: ctx.Err()} case <-t.C: } } if err := ctx.Err(); err != nil { - return SendReceipt{Submitted: false, Err: err} + return SendReceipt{Submitted: false, Kind: SendErrorCancelled, Err: err} } switch f.Behavior { case FakeAlwaysFail: - return SendReceipt{Submitted: false, Err: fmt.Errorf("fake failure")} + return SendReceipt{Submitted: false, Kind: SendErrorRecipient, Err: fmt.Errorf("fake failure")} case FakeFatal: if n == 1 { - return SendReceipt{Submitted: false, Fatal: true, Err: fmt.Errorf("fake fatal error")} + return SendReceipt{Submitted: false, Kind: SendErrorFatal, Fatal: true, Err: fmt.Errorf("fake fatal error")} } } if f.FailFor != nil { for _, to := range msg.To { if f.FailFor[strings.ToLower(to)] { - return SendReceipt{Submitted: false, Err: fmt.Errorf("fake failure for %s", to)} + return SendReceipt{Submitted: false, Kind: SendErrorRecipient, Err: fmt.Errorf("fake failure for %s", to)} } } } @@ -111,5 +98,9 @@ func (f *FakeSender) Send(ctx context.Context, msg RenderedMessage) SendReceipt f.mu.Lock() f.Sent = append(f.Sent, msg) f.mu.Unlock() - return SendReceipt{Submitted: true, Info: "fake submitted"} + info := "fake submitted" + if msg.SaveAsDraft { + info = "fake draft saved" + } + return SendReceipt{Submitted: true, Info: info} } diff --git a/backend/campaign/renderer.go b/backend/campaign/renderer.go index 785c0d8..56c5bab 100644 --- a/backend/campaign/renderer.go +++ b/backend/campaign/renderer.go @@ -13,21 +13,26 @@ import ( // Renderer turns a Campaign + Contact into a RenderedMessage. It is the single // rendering path shared by preview, test send, and bulk send. type Renderer struct { - mf *mergefield.Renderer - stat func(string) (os.FileInfo, error) + mf *mergefield.Renderer + stat func(string) (os.FileInfo, error) + attachmentCache map[string]ResolvedAttachment } // NewRenderer builds a renderer for a campaign's schema. func NewRenderer(c Campaign) *Renderer { return &Renderer{ - mf: mergefield.NewRenderer(c.Schema()), - stat: os.Stat, + mf: mergefield.NewRenderer(c.Schema()), + stat: os.Stat, + attachmentCache: make(map[string]ResolvedAttachment), } } -// withStat overrides the filesystem stat function (used in tests). +// withStat overrides the filesystem stat function (used in tests). Replacing the +// stat source also resets cached metadata so tests and callers never observe +// entries produced by a previous stat implementation. func (r *Renderer) withStat(fn func(string) (os.FileInfo, error)) *Renderer { r.stat = fn + r.attachmentCache = make(map[string]ResolvedAttachment) return r } @@ -37,17 +42,13 @@ func (r *Renderer) Render(c Campaign, contact models.Contact) RenderedMessage { contact.Email = c.ToOverride } - msg := RenderedMessage{IsHTML: c.IsHTML} + msg := RenderedMessage{IsHTML: c.IsHTML, SaveAsDraft: c.DraftOnly} var diags []mergefield.Diagnostic subject, sd := r.mf.Render(c.SubjectTemplate, contact, false) msg.Subject = subject diags = append(diags, sd...) - // Render both bodies; the appropriate one is escaped for its context. - // HTML body: merge values are escaped during render, then the whole body is - // normalized for email clients and sanitized (defense in depth against any - // script/handler/unsafe-URL in the authored template). htmlBody, hd := r.mf.Render(c.BodyTemplate, contact, true) textBody, _ := r.mf.Render(c.BodyTemplate, contact, false) msg.HTMLBody = htmlutil.Sanitize(htmlutil.NormalizeForEmail(htmlBody)) @@ -56,14 +57,12 @@ func (r *Renderer) Render(c Campaign, contact models.Contact) RenderedMessage { diags = append(diags, hd...) } - // To toAddr := c.ToOverride if toAddr == "" { toAddr = contact.Email } msg.To = renderAddressList(r.mf, toAddr, contact) - // CC / BCC if c.CCTemplate != "" { cc, _ := r.mf.Render(c.CCTemplate, contact, false) msg.CC = renderAddressList(r.mf, cc, contact) @@ -73,7 +72,6 @@ func (r *Renderer) Render(c Campaign, contact models.Contact) RenderedMessage { msg.BCC = renderAddressList(r.mf, bcc, contact) } - // Attachments (personalized paths resolved and validated). for _, a := range c.Attachments { path, _ := r.mf.Render(a, contact, false) msg.Attachments = append(msg.Attachments, r.resolveAttachment(path)) @@ -83,23 +81,25 @@ func (r *Renderer) Render(c Campaign, contact models.Contact) RenderedMessage { return msg } -// renderAddressList renders a template into a normalized address list. func renderAddressList(mf *mergefield.Renderer, rendered string, _ models.Contact) []string { valid, invalid := email.ParseList(rendered) out := make([]string, 0, len(valid)+len(invalid)) for _, a := range valid { out = append(out, a.String()) } - // Keep invalid entries too so preflight can flag them; they are not silently - // dropped. out = append(out, invalid...) return out } func (r *Renderer) resolveAttachment(path string) ResolvedAttachment { + if cached, ok := r.attachmentCache[path]; ok { + return cached + } + ra := ResolvedAttachment{Path: path, Name: filepath.Base(path)} if path == "" { ra.Error = "empty attachment path" + r.attachmentCache[path] = ra return ra } info, err := r.stat(path) @@ -109,6 +109,7 @@ func (r *Renderer) resolveAttachment(path string) ResolvedAttachment { } else { ra.Error = err.Error() } + r.attachmentCache[path] = ra return ra } ra.Exists = true @@ -117,5 +118,6 @@ func (r *Renderer) resolveAttachment(path string) ResolvedAttachment { if ra.IsDir { ra.Error = "attachment path is a directory" } + r.attachmentCache[path] = ra return ra } diff --git a/backend/campaign/renderer_test.go b/backend/campaign/renderer_test.go index 17a6805..89c925c 100644 --- a/backend/campaign/renderer_test.go +++ b/backend/campaign/renderer_test.go @@ -1,6 +1,7 @@ package campaign import ( + "os" "strings" "testing" @@ -36,8 +37,6 @@ func TestRenderEquivalencePreviewTestBulk(t *testing.T) { preview := r.Render(c, richContact()) - // Test send: same campaign but with an explicit To override that does not - // overwrite the {{email}} merge value. test := c test.ToOverride = "qa@example.com" testMsg := NewRenderer(test).Render(test, richContact()) @@ -53,7 +52,6 @@ func TestRenderEquivalencePreviewTestBulk(t *testing.T) { if strings.Join(preview.CC, ",") != strings.Join(bulk.CC, ",") { t.Errorf("CC differ") } - // The test send goes to the override, but merge data (body) is unchanged. if len(testMsg.To) == 0 || !strings.Contains(testMsg.To[0], "qa@example.com") { t.Errorf("test To = %v, want qa override", testMsg.To) } @@ -62,7 +60,7 @@ func TestRenderEquivalencePreviewTestBulk(t *testing.T) { func TestRenderTestSendDoesNotOverwriteEmailByDefault(t *testing.T) { c := richCampaign() c.BodyTemplate = "Your email is {{email}}" - c.ToOverride = "qa@example.com" // OverrideEmail defaults to false + c.ToOverride = "qa@example.com" msg := NewRenderer(c).Render(c, richContact()) if !strings.Contains(msg.TextBody, "ada@example.com") { t.Errorf("{{email}} should still render the contact address, got %q", msg.TextBody) @@ -106,8 +104,36 @@ func TestRenderHTMLBodyEscapesMergeValues(t *testing.T) { if !strings.Contains(msg.HTMLBody, "<b>") { t.Errorf("expected escaped value in HTML body: %q", msg.HTMLBody) } - // The authored

markup is preserved (normalization may add inline styles). if !strings.Contains(msg.HTMLBody, " 0 && !onlySet[rr.ContactIndex] { continue } - targets = append(targets, pos) + if rr.ContactIndex < 0 || rr.ContactIndex >= len(c.Contacts) { + continue + } + candidatePositions = append(candidatePositions, pos) + retryContacts = append(retryContacts, c.Contacts[rr.ContactIndex]) + } + + if len(candidatePositions) == 0 { + out := CampaignResult{ + State: CampaignPreflightFailed, + FatalError: "no failed recipients are eligible for retry", + RecipientResults: results, + } + tally(&out) + return r.withTiming(out, started) + } + + retryCampaign := c + retryCampaign.Contacts = retryContacts + pf := r.preflight.Preflight(ctx, retryCampaign, r.sender) + if !pf.CanSend { + out := CampaignResult{ + State: CampaignPreflightFailed, + RecipientResults: results, + Preflight: &pf, + } + tally(&out) + return r.withTiming(out, started) } - final := r.execute(ctx, c, results, targets, sink) - final.Preflight = prev.Preflight - return final + targets := make([]int, 0, len(pf.Recipients)) + for _, retryIdx := range pf.Recipients { + if retryIdx >= 0 && retryIdx < len(candidatePositions) { + targets = append(targets, candidatePositions[retryIdx]) + } + } + + final := r.execute(ctx, c, results, targets, sink, "retry") + final.Preflight = &pf + return r.withTiming(final, started) } // execute sends to the recipient positions in targets, recording attempts. -func (r *Runner) execute(ctx context.Context, c Campaign, results []RecipientResult, targets []int, sink ProgressSink) CampaignResult { +func (r *Runner) execute(ctx context.Context, c Campaign, results []RecipientResult, targets []int, sink ProgressSink, trigger string) CampaignResult { renderer := NewRenderer(c) if r.stat != nil { renderer.withStat(r.stat) @@ -114,7 +150,7 @@ func (r *Runner) execute(ctx context.Context, c Campaign, results []RecipientRes cancelRemaining := func(from int, status RecipientStatus) { for j := from; j < len(targets); j++ { pos := targets[j] - r.appendAttempt(&results[pos], status, "") + r.appendAttempt(&results[pos], status, "", trigger) } } @@ -125,7 +161,6 @@ func (r *Runner) execute(ctx context.Context, c Campaign, results []RecipientRes break } - // Inter-message delay (skipped before the very first send). if i > 0 && opts.DelayBetweenMessages > 0 { if err := r.clock.Sleep(ctx, opts.DelayBetweenMessages); err != nil { cancelRemaining(i, RecipientCancelled) @@ -133,7 +168,6 @@ func (r *Runner) execute(ctx context.Context, c Campaign, results []RecipientRes break } } - // Batch pause. if opts.BatchSize > 0 && i > 0 && i%opts.BatchSize == 0 && opts.PauseBetweenBatches > 0 { if err := r.clock.Sleep(ctx, opts.PauseBetweenBatches); err != nil { cancelRemaining(i, RecipientCancelled) @@ -148,9 +182,16 @@ func (r *Runner) execute(ctx context.Context, c Campaign, results []RecipientRes msg := renderer.Render(c, c.Contacts[rr.ContactIndex]) receipt := r.sender.Send(ctx, msg) - if receipt.Fatal { + if receipt.Kind == SendErrorCancelled { + r.appendAttempt(rr, RecipientCancelled, receiptError(receipt), trigger) + cancelRemaining(i+1, RecipientCancelled) + state = CampaignCancelled + break + } + + if receipt.Fatal || receipt.Kind == SendErrorFatal { errMsg := receiptError(receipt) - r.appendAttempt(rr, RecipientFailed, errMsg) + r.appendAttempt(rr, RecipientFailed, errMsg, trigger) sink.Progress(Progress{Current: i + 1, Total: total, Status: "failure", Email: rr.Email, Message: errMsg}) cancelRemaining(i+1, RecipientCancelled) state = CampaignRuntimeFailed @@ -159,14 +200,15 @@ func (r *Runner) execute(ctx context.Context, c Campaign, results []RecipientRes } if receipt.Submitted { - r.appendAttempt(rr, RecipientSubmitted, "") + r.appendAttempt(rr, RecipientSubmitted, "", trigger) sink.Progress(Progress{Current: i + 1, Total: total, Status: "success", Email: rr.Email}) } else { errMsg := receiptError(receipt) - r.appendAttempt(rr, RecipientFailed, errMsg) + r.appendAttempt(rr, RecipientFailed, errMsg, trigger) sink.Progress(Progress{Current: i + 1, Total: total, Status: "failure", Email: rr.Email, Message: errMsg}) if !opts.ContinueOnError { cancelRemaining(i+1, RecipientSkipped) + state = CampaignStoppedOnFailure break } } @@ -174,21 +216,32 @@ func (r *Runner) execute(ctx context.Context, c Campaign, results []RecipientRes out := CampaignResult{State: state, FatalError: fatalErr, RecipientResults: results} tally(&out) - sink.Progress(Progress{Current: total, Total: total, Status: "complete", - Message: summaryMessage(out)}) + if out.State == CampaignCompleted && out.Failed > 0 { + out.State = CampaignCompletedWithFailures + } + sink.Progress(Progress{Current: total, Total: total, Status: "complete", Message: summaryMessage(out)}) return out } -func (r *Runner) appendAttempt(rr *RecipientResult, status RecipientStatus, errMsg string) { +func (r *Runner) appendAttempt(rr *RecipientResult, status RecipientStatus, errMsg, trigger string) { rr.Attempts = append(rr.Attempts, Attempt{ Number: len(rr.Attempts) + 1, Status: status, Error: errMsg, + Trigger: trigger, Timestamp: r.clock.Now(), }) rr.Status = status } +func (r *Runner) withTiming(out CampaignResult, started time.Time) CampaignResult { + finished := r.clock.Now() + out.StartedAt = started + out.FinishedAt = finished + out.Duration = finished.Sub(started) + return out +} + // tally recomputes counters from final recipient statuses so retries never // double-count. func tally(out *CampaignResult) { diff --git a/backend/campaign/runner_test.go b/backend/campaign/runner_test.go index bab60ca..6541136 100644 --- a/backend/campaign/runner_test.go +++ b/backend/campaign/runner_test.go @@ -2,6 +2,7 @@ package campaign import ( "context" + "os" "sync" "testing" "time" @@ -85,10 +86,12 @@ func TestRunCompleteSuccess(t *testing.T) { if len(fs.Sent) != 3 { t.Errorf("fake sent %d, want 3", len(fs.Sent)) } - // Delay applied between messages: 2 sleeps of 500ms (not before the first). if clk.totalSleep() != time.Second { t.Errorf("total sleep = %v, want 1s", clk.totalSleep()) } + if res.StartedAt.IsZero() || res.FinishedAt.IsZero() || res.Duration <= 0 { + t.Errorf("expected non-zero timing, got start=%v finish=%v duration=%v", res.StartedAt, res.FinishedAt, res.Duration) + } } func TestRunPartialFailure(t *testing.T) { @@ -97,8 +100,8 @@ func TestRunPartialFailure(t *testing.T) { r := newTestRunner(fs, newFakeClock()) res := r.Run(context.Background(), baseCampaign("a@x.com", "b@x.com", "c@x.com"), nil) - if res.State != CampaignCompleted { - t.Fatalf("state = %v", res.State) + if res.State != CampaignCompletedWithFailures { + t.Fatalf("state = %v, want completed_with_failures", res.State) } if res.Submitted != 2 || res.Failed != 1 { t.Errorf("submitted=%d failed=%d, want 2/1", res.Submitted, res.Failed) @@ -116,7 +119,6 @@ func TestRunFatalSenderFailure(t *testing.T) { if res.FatalError == "" { t.Error("expected fatal error message") } - // Fatal must not look like a zero-failure success. if res.Submitted != 0 { t.Errorf("submitted = %d, want 0", res.Submitted) } @@ -129,7 +131,6 @@ func TestRunCancellation(t *testing.T) { fs := NewFakeSender() clk := newFakeClock() ctx, cancel := context.WithCancel(context.Background()) - // Cancel after the first send via a progress sink. var n int sink := ProgressFunc(func(p Progress) { if p.Status == "success" { @@ -151,6 +152,9 @@ func TestRunCancellation(t *testing.T) { if res.Cancelled != 2 { t.Errorf("cancelled = %d, want 2", res.Cancelled) } + if res.Duration <= 0 { + t.Errorf("cancelled run duration = %v, want > 0", res.Duration) + } } func TestRetryOnlyFailedNoDoubleCount(t *testing.T) { @@ -165,7 +169,6 @@ func TestRetryOnlyFailedNoDoubleCount(t *testing.T) { t.Fatalf("first run failed = %d", first.Failed) } - // Now let b succeed and retry. fs.FailFor = nil retried := r.Retry(context.Background(), c, first, nil, nil) @@ -175,21 +178,89 @@ func TestRetryOnlyFailedNoDoubleCount(t *testing.T) { if retried.Failed != 0 { t.Errorf("after retry failed = %d, want 0", retried.Failed) } - // The successful recipients must not have been re-sent. - // Only b was retried => one additional Send beyond the original 3. if len(fs.All) != 4 { t.Errorf("total Send calls = %d, want 4 (3 initial + 1 retry)", len(fs.All)) } - // Attempt history preserved for b (failed then submitted). for _, rr := range retried.RecipientResults { if rr.Email == "b@x.com" { if len(rr.Attempts) != 2 { t.Errorf("b attempts = %d, want 2", len(rr.Attempts)) } + if rr.Attempts[1].Trigger != "retry" { + t.Errorf("retry trigger = %q, want retry", rr.Attempts[1].Trigger) + } } } } +func TestRetryRunsFreshPreflightForSuppression(t *testing.T) { + fs := NewFakeSender() + fs.FailFor = map[string]bool{"b@x.com": true} + r := newTestRunner(fs, newFakeClock()) + c := baseCampaign("a@x.com", "b@x.com") + first := r.Run(context.Background(), c, nil) + + fs.FailFor = nil + c.Suppressed = map[string]bool{"b@x.com": true} + retried := r.Retry(context.Background(), c, first, nil, nil) + if retried.State != CampaignPreflightFailed { + t.Fatalf("state = %v, want preflight_failed", retried.State) + } + if retried.Preflight == nil || retried.Preflight.SuppressedCount != 1 { + t.Fatalf("fresh preflight suppression count = %#v", retried.Preflight) + } + if len(fs.All) != 2 { + t.Errorf("Send calls = %d, want 2; retry must be blocked", len(fs.All)) + } +} + +func TestRetryRunsFreshPreflightForSenderAvailability(t *testing.T) { + fs := NewFakeSender() + fs.FailFor = map[string]bool{"b@x.com": true} + r := newTestRunner(fs, newFakeClock()) + c := baseCampaign("a@x.com", "b@x.com") + first := r.Run(context.Background(), c, nil) + + fs.FailFor = nil + fs.Behavior = FakeUnavailable + retried := r.Retry(context.Background(), c, first, nil, nil) + if retried.State != CampaignPreflightFailed { + t.Fatalf("state = %v, want preflight_failed", retried.State) + } + if retried.Preflight == nil || retried.Preflight.SenderStatus.Available { + t.Fatal("retry should include a fresh unavailable sender preflight") + } +} + +func TestRetryRunsFreshPreflightForRemovedAttachment(t *testing.T) { + fs := NewFakeSender() + fs.FailFor = map[string]bool{"b@x.com": true} + r := newTestRunner(fs, newFakeClock()) + c := baseCampaign("a@x.com", "b@x.com") + + f, err := os.CreateTemp(t.TempDir(), "attachment-*.txt") + if err != nil { + t.Fatal(err) + } + path := f.Name() + if err := f.Close(); err != nil { + t.Fatal(err) + } + c.Attachments = []string{path} + first := r.Run(context.Background(), c, nil) + if first.Failed != 1 { + t.Fatalf("first failed=%d, want 1", first.Failed) + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + fs.FailFor = nil + retried := r.Retry(context.Background(), c, first, nil, nil) + if retried.State != CampaignPreflightFailed { + t.Fatalf("state = %v, want preflight_failed", retried.State) + } +} + func TestDuplicateExclusion(t *testing.T) { fs := NewFakeSender() r := newTestRunner(fs, newFakeClock()) @@ -251,7 +322,6 @@ func TestBatchPauses(t *testing.T) { if res.Submitted != 4 { t.Fatalf("submitted = %d", res.Submitted) } - // Batch pause fires at i==2 (start of the 2nd batch): 1 pause of 1s. if clk.totalSleep() != time.Second { t.Errorf("total sleep = %v, want 1s", clk.totalSleep()) } @@ -273,6 +343,9 @@ func TestContinueOnErrorFalseStops(t *testing.T) { c := baseCampaign("a@x.com", "b@x.com", "c@x.com") c.Options.ContinueOnError = false res := r.Run(context.Background(), c, nil) + if res.State != CampaignStoppedOnFailure { + t.Errorf("state = %v, want stopped_on_failure", res.State) + } if res.Failed != 1 { t.Errorf("failed = %d, want 1", res.Failed) } @@ -281,6 +354,20 @@ func TestContinueOnErrorFalseStops(t *testing.T) { } } +func TestDraftModeFlowsToSender(t *testing.T) { + fs := NewFakeSender() + r := newTestRunner(fs, newFakeClock()) + c := baseCampaign("a@x.com") + c.DraftOnly = true + res := r.Run(context.Background(), c, nil) + if res.State != CampaignCompleted || len(fs.All) != 1 { + t.Fatalf("unexpected result: state=%v sends=%d", res.State, len(fs.All)) + } + if !fs.All[0].SaveAsDraft { + t.Error("rendered message did not carry draft-only mode") + } +} + func TestProgressOrdering(t *testing.T) { fs := NewFakeSender() r := newTestRunner(fs, newFakeClock()) diff --git a/backend/campaign/types.go b/backend/campaign/types.go index 3d2c0e7..a4bb5e5 100644 --- a/backend/campaign/types.go +++ b/backend/campaign/types.go @@ -39,7 +39,7 @@ const ( func DefaultSendOptions() SendOptions { return SendOptions{ DelayBetweenMessages: 500 * time.Millisecond, - BatchSize: 0, // 0 = no batching + BatchSize: 0, PauseBetweenBatches: 0, ConfirmBeforeSend: true, ContinueOnError: true, @@ -71,19 +71,20 @@ func (o SendOptions) Normalized() SendOptions { // Campaign is a fully-specified merge job. type Campaign struct { - Headers []string // import column headers, for the merge schema - Contacts []models.Contact // recipients (pre-dedupe) + Headers []string + Contacts []models.Contact SubjectTemplate string BodyTemplate string IsHTML bool - Attachments []string // global attachment paths (may contain merge tokens) - CCTemplate string // rendered per contact (may be static) - BCCTemplate string // rendered per contact (may be static) - ToOverride string // if set (test send), used as the To recipient - OverrideEmail bool // if true, ToOverride also replaces the {{email}} merge value + Attachments []string + CCTemplate string + BCCTemplate string + ToOverride string + OverrideEmail bool + DraftOnly bool Options SendOptions DuplicatePolicy email.Policy - Suppressed map[string]bool // normalized suppressed addresses + Suppressed map[string]bool } // Schema builds the merge-field schema for this campaign. @@ -110,6 +111,7 @@ type RenderedMessage struct { HTMLBody string `json:"htmlBody"` TextBody string `json:"textBody"` IsHTML bool `json:"isHTML"` + SaveAsDraft bool `json:"saveAsDraft"` Attachments []ResolvedAttachment `json:"attachments"` Diagnostics []mergefield.Diagnostic `json:"diagnostics"` } @@ -152,23 +154,37 @@ type SenderStatus struct { Message string `json:"message"` } +// SendErrorKind classifies a sender failure so the runner can make a safe +// campaign-level decision without depending on sender-specific error strings. +type SendErrorKind string + +const ( + SendErrorRecipient SendErrorKind = "recipient" + SendErrorTransient SendErrorKind = "transient" + SendErrorFatal SendErrorKind = "fatal" + SendErrorCancelled SendErrorKind = "cancelled" +) + // SendReceipt is the outcome of a single Send call. type SendReceipt struct { - Submitted bool `json:"submitted"` - Fatal bool `json:"fatal"` // true if the sender is now unusable (e.g. Outlook died) - Info string `json:"info,omitempty"` - Err error `json:"-"` - ErrMsg string `json:"error,omitempty"` + Submitted bool `json:"submitted"` + Kind SendErrorKind `json:"kind,omitempty"` + Fatal bool `json:"fatal"` + Info string `json:"info,omitempty"` + Err error `json:"-"` + ErrMsg string `json:"error,omitempty"` } // CampaignState is the terminal state of a campaign run. type CampaignState string const ( - CampaignCompleted CampaignState = "completed" - CampaignCancelled CampaignState = "cancelled" - CampaignPreflightFailed CampaignState = "preflight_failed" - CampaignRuntimeFailed CampaignState = "runtime_failed" + CampaignCompleted CampaignState = "completed" + CampaignCompletedWithFailures CampaignState = "completed_with_failures" + CampaignStoppedOnFailure CampaignState = "stopped_on_failure" + CampaignCancelled CampaignState = "cancelled" + CampaignPreflightFailed CampaignState = "preflight_failed" + CampaignRuntimeFailed CampaignState = "runtime_failed" ) // RecipientStatus is the status of a single recipient attempt. @@ -186,6 +202,7 @@ type Attempt struct { Number int `json:"number"` Status RecipientStatus `json:"status"` Error string `json:"error,omitempty"` + Trigger string `json:"trigger,omitempty"` Timestamp time.Time `json:"timestamp" ts_type:"string"` } @@ -201,13 +218,18 @@ type RecipientResult struct { Attempts []Attempt `json:"attempts"` } -// lastFailed reports whether the recipient's most recent attempt failed. func (r RecipientResult) lastFailed() bool { return r.Status == RecipientFailed } // CampaignResult is the typed outcome of a campaign run. A fatal preflight or // Outlook failure is never reported as an empty successful result. type CampaignResult struct { + CampaignID string `json:"campaignId,omitempty"` + ParentCampaignID string `json:"parentCampaignId,omitempty"` + RunNumber int `json:"runNumber,omitempty"` State CampaignState `json:"state"` + StartedAt time.Time `json:"startedAt" ts_type:"string"` + FinishedAt time.Time `json:"finishedAt" ts_type:"string"` + Duration time.Duration `json:"duration"` Attempted int `json:"attempted"` Submitted int `json:"submitted"` Failed int `json:"failed"` diff --git a/backend/graph/doc.go b/backend/graph/doc.go index 1efe5ab..f130868 100644 --- a/backend/graph/doc.go +++ b/backend/graph/doc.go @@ -23,7 +23,7 @@ import ( ) // ErrNotImplemented is returned by the stub sender until Graph support lands. -var ErrNotImplemented = errors.New("Microsoft Graph sender is not implemented yet") +var ErrNotImplemented = errors.New("microsoft Graph sender is not implemented yet") // Config holds the (future) Graph sender configuration. Tokens are never stored // in plaintext; TokenStore is expected to use the OS credential store / DPAPI. diff --git a/backend/models/contact.go b/backend/models/contact.go index 0e967bc..788a7d2 100644 --- a/backend/models/contact.go +++ b/backend/models/contact.go @@ -77,6 +77,7 @@ type EmailRequest struct { BCC string `json:"bcc,omitempty"` // Static BCC addresses (comma-separated) CCTemplate string `json:"ccTemplate,omitempty"` // CC with merge fields support BCCTemplate string `json:"bccTemplate,omitempty"` // BCC with merge fields support + DraftOnly bool `json:"draftOnly,omitempty"` // Save to Outlook Drafts instead of sending } // TestEmailRequest represents a request to send a single test email. @@ -102,6 +103,7 @@ type TestEmailRequest struct { // OverwriteEmail, when true, makes TestAddress also replace the {{email}} // merge value; otherwise {{email}} keeps the contact's own address. OverwriteEmail bool `json:"overwriteEmail"` + DraftOnly bool `json:"draftOnly,omitempty"` // Save to Outlook Drafts instead of sending } // ProgressUpdate represents a real-time progress update during bulk sending. diff --git a/backend/outlook/outlook_windows.go b/backend/outlook/outlook_windows.go index 57d3f15..c4f9338 100644 --- a/backend/outlook/outlook_windows.go +++ b/backend/outlook/outlook_windows.go @@ -5,21 +5,15 @@ Package outlook provides the classic-Outlook COM implementation of campaign.EmailSender. All COM access is confined to a single dedicated OS thread (a locked STA worker), so the rest of the application — including the campaign runner — never touches go-ole and remains testable without Outlook installed. - -Threading model: - - One goroutine calls runtime.LockOSThread and initializes COM once. - - All COM work is submitted as commands over a channel and executed serially on - that thread; the Outlook.Application object is created lazily and reused. - - CoUninitialize is called on shutdown only when this code successfully - acquired a COM initialization reference (S_OK or S_FALSE), never after - RPC_E_CHANGED_MODE. */ package outlook import ( "context" + "errors" "fmt" "runtime" + "strings" "sync" "MailMergeApp/backend/campaign" @@ -28,15 +22,12 @@ import ( "github.com/go-ole/go-ole/oleutil" ) -// COM HRESULT codes we handle explicitly. const ( sOK = 0 sFALSE = 1 rpcEChangedMode = 0x80010106 ) -// command is a unit of work executed on the STA worker thread. fn receives the -// live, reused Outlook.Application COM object. type command struct { fn func(app *ole.IDispatch) error reply chan error @@ -48,8 +39,8 @@ type Sender struct { stopOnce sync.Once cmds chan command done chan struct{} + stopped chan struct{} - // startErr is set once during startup and read after ready closes. ready chan struct{} startErr error @@ -60,18 +51,21 @@ type Sender struct { // New returns a classic-Outlook sender. The STA worker starts lazily on first use. func New() *Sender { return &Sender{ - cmds: make(chan command), - done: make(chan struct{}), - ready: make(chan struct{}), + cmds: make(chan command), + done: make(chan struct{}), + stopped: make(chan struct{}), + ready: make(chan struct{}), } } -// Close shuts down the STA worker and releases COM. +// Close shuts down the STA worker, waits for any in-flight COM command to finish, +// and releases COM-owned resources before returning. func (s *Sender) Close() { + s.start() s.stopOnce.Do(func() { close(s.done) }) + <-s.stopped } -// start launches the STA worker exactly once. func (s *Sender) start() { s.startOnce.Do(func() { go s.loop() @@ -79,10 +73,10 @@ func (s *Sender) start() { <-s.ready } -// loop runs on a single locked OS thread for the lifetime of the sender. func (s *Sender) loop() { runtime.LockOSThread() defer runtime.UnlockOSThread() + defer close(s.stopped) ownsCOM, err := initCOM() s.startErr = err @@ -91,7 +85,6 @@ func (s *Sender) loop() { } close(s.ready) if err != nil { - // Drain commands with the startup error until closed. for { select { case <-s.done: @@ -127,12 +120,13 @@ func (s *Sender) loop() { } } -// initCOM initializes COM on the current thread and reports whether this call -// acquired an initialization reference that must be released with CoUninitialize. +// initCOM initializes COM on the locked worker thread. RPC_E_CHANGED_MODE is a +// startup failure because this worker promises an STA; continuing in an +// incompatible apartment would make Outlook automation unsafe. func initCOM() (ownsCOM bool, err error) { e := ole.CoInitializeEx(0, ole.COINIT_APARTMENTTHREADED) if e == nil { - return true, nil // S_OK + return true, nil } oleErr, ok := e.(*ole.OleError) if !ok { @@ -142,9 +136,7 @@ func initCOM() (ownsCOM bool, err error) { case sOK, sFALSE: return true, nil case rpcEChangedMode: - // COM already initialized on this thread with a different mode. We did - // NOT acquire a matching reference, so must not CoUninitialize. - return false, nil + return false, fmt.Errorf("failed to initialize Outlook STA worker: COM apartment mode is incompatible (RPC_E_CHANGED_MODE): %w", e) default: return false, fmt.Errorf("failed to initialize COM (hresult 0x%x): %w", oleErr.Code(), e) } @@ -163,7 +155,6 @@ func createOutlookApp() (*ole.IDispatch, error) { return app, nil } -// submit runs fn on the STA worker and returns its error (or a context error). func (s *Sender) submit(ctx context.Context, fn func(app *ole.IDispatch) error) error { s.start() if s.startErr != nil { @@ -174,32 +165,34 @@ func (s *Sender) submit(ctx context.Context, fn func(app *ole.IDispatch) error) select { case <-ctx.Done(): return ctx.Err() - case s.cmds <- c: case <-s.done: return fmt.Errorf("sender is closed") + case s.cmds <- c: } select { case <-ctx.Done(): return ctx.Err() case e := <-reply: return e + case <-s.stopped: + return fmt.Errorf("sender closed before COM command completed") } } -// Capabilities reports classic-Outlook COM capabilities. +// Capabilities reports only behavior implemented by this sender. Account and +// shared-mailbox selection are intentionally false until explicit selection is +// implemented and tested. func (s *Sender) Capabilities(context.Context) campaign.SenderCapabilities { return campaign.SenderCapabilities{ SupportsHTML: true, SupportsAttachments: true, - SupportsMultipleAccounts: true, - SupportsSharedMailbox: true, + SupportsMultipleAccounts: false, + SupportsSharedMailbox: false, SupportsDraftOnly: true, SupportsScheduling: false, } } -// Preflight probes whether classic Outlook is reachable via COM and returns an -// actionable status. It does not falsely claim support for new Outlook. func (s *Sender) Preflight(ctx context.Context) campaign.SenderStatus { err := s.submit(ctx, func(app *ole.IDispatch) error { if app == nil { @@ -211,31 +204,65 @@ func (s *Sender) Preflight(ctx context.Context) campaign.SenderStatus { return campaign.SenderStatus{Available: true, State: campaign.StateClassicOutlook, Message: "Classic Outlook is available via COM automation."} } + state := campaign.StateUnavailable + if s.startErr != nil { + state = campaign.StateCOMInaccessible + } return campaign.SenderStatus{ Available: false, - State: campaign.StateUnavailable, + State: state, Message: "Could not reach Microsoft Outlook via COM automation. Ensure classic " + "Outlook (not New Outlook, which does not expose COM) is installed and a " + "mail account is configured. Details: " + err.Error(), } } -// Send submits one rendered message through Outlook on the STA worker. func (s *Sender) Send(ctx context.Context, msg campaign.RenderedMessage) campaign.SendReceipt { err := s.submit(ctx, func(app *ole.IDispatch) error { return sendMessage(app, msg) }) if err != nil { - // Treat inability to reach Outlook as fatal so the run stops cleanly. - fatal := s.startErr != nil - return campaign.SendReceipt{Submitted: false, Fatal: fatal, Err: err} + kind := classifySendError(err, s.startErr) + return campaign.SendReceipt{ + Submitted: false, + Kind: kind, + Fatal: kind == campaign.SendErrorFatal, + Err: err, + } + } + info := "submitted to Outlook" + if msg.SaveAsDraft { + info = "saved to Outlook Drafts" } - return campaign.SendReceipt{Submitted: true, Info: "submitted to Outlook"} + return campaign.SendReceipt{Submitted: true, Info: info} } -// sendMessage builds and sends a MailItem. Runs on the STA thread. +func classifySendError(err error, startErr error) campaign.SendErrorKind { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return campaign.SendErrorCancelled + } + if startErr != nil { + return campaign.SendErrorFatal + } + msg := strings.ToLower(err.Error()) + fatalMarkers := []string{ + "sender is closed", + "sender closed before com command completed", + "outlook is not installed or not accessible via com", + "failed to create mail item", + "failed to get outlook interface", + } + for _, marker := range fatalMarkers { + if strings.Contains(msg, marker) { + return campaign.SendErrorFatal + } + } + return campaign.SendErrorTransient +} + +// sendMessage builds and either sends or saves a MailItem. Runs on the STA thread. func sendMessage(app *ole.IDispatch, msg campaign.RenderedMessage) error { - itemVar, err := oleutil.CallMethod(app, "CreateItem", 0) // olMailItem = 0 + itemVar, err := oleutil.CallMethod(app, "CreateItem", 0) if err != nil { return fmt.Errorf("failed to create mail item: %w", err) } @@ -287,8 +314,12 @@ func sendMessage(app *ole.IDispatch, msg campaign.RenderedMessage) error { } } - if _, err := oleutil.CallMethod(item, "Send"); err != nil { - return fmt.Errorf("failed to send email: %w", err) + method := "Send" + if msg.SaveAsDraft { + method = "Save" + } + if _, err := oleutil.CallMethod(item, method); err != nil { + return fmt.Errorf("failed to %s email: %w", strings.ToLower(method), err) } return nil } diff --git a/backend/services/atomic.go b/backend/services/atomic.go index d706256..0d7bbf9 100644 --- a/backend/services/atomic.go +++ b/backend/services/atomic.go @@ -22,11 +22,11 @@ func atomicWrite(path string, data []byte, perm os.FileMode) error { defer func() { _ = os.Remove(tmpName) }() if _, err := tmp.Write(data); err != nil { - tmp.Close() + _ = tmp.Close() return fmt.Errorf("failed to write temp file: %w", err) } if err := tmp.Sync(); err != nil { - tmp.Close() + _ = tmp.Close() return fmt.Errorf("failed to flush temp file: %w", err) } if err := tmp.Close(); err != nil { diff --git a/backend/services/file_service.go b/backend/services/file_service.go index 14f824b..f682895 100644 --- a/backend/services/file_service.go +++ b/backend/services/file_service.go @@ -98,7 +98,7 @@ func (fs *FileService) parseCSV(filePath string) (*models.ParseResult, error) { if err != nil { return nil, fmt.Errorf("failed to open CSV file: %w", err) } - defer file.Close() + defer func() { _ = file.Close() }() reader := csv.NewReader(file) @@ -201,12 +201,12 @@ func (fs *FileService) parseExcel(filePath string) (*models.ParseResult, error) if err != nil { return nil, fmt.Errorf("failed to open Excel file: %w", err) } - defer f.Close() + defer func() { _ = f.Close() }() // Get the first sheet sheets := f.GetSheetList() if len(sheets) == 0 { - return nil, fmt.Errorf("Excel file contains no sheets") + return nil, fmt.Errorf("excel file contains no sheets") } rows, err := f.GetRows(sheets[0]) @@ -215,7 +215,7 @@ func (fs *FileService) parseExcel(filePath string) (*models.ParseResult, error) } if len(rows) == 0 { - return nil, fmt.Errorf("Excel sheet is empty") + return nil, fmt.Errorf("excel sheet is empty") } if len(rows[0]) > MaxImportColumns { @@ -279,13 +279,13 @@ func (fs *FileService) findColumnIndices(header []string) map[string]int { normalized = strings.ReplaceAll(normalized, sep, "") } - // Handle various column name formats - switch { - case normalized == "firstname" || normalized == "fname" || normalized == "givenname": + // Handle various column name formats. + switch normalized { + case "firstname", "fname", "givenname": indices["firstname"] = i - case normalized == "lastname" || normalized == "lname" || normalized == "surname": + case "lastname", "lname", "surname": indices["lastname"] = i - case normalized == "email" || normalized == "emailaddress" || normalized == "mail": + case "email", "emailaddress", "mail": indices["email"] = i } } diff --git a/backend/services/services_test.go b/backend/services/services_test.go index 106a5bb..38537bd 100644 --- a/backend/services/services_test.go +++ b/backend/services/services_test.go @@ -86,7 +86,7 @@ func TestGetRecentFilesReturnsCopyAndPrunes(t *testing.T) { t.Errorf("GetRecentFiles should prune missing files, got %v", got) } // Mutating the returned slice must not affect internal state. - got = append(got, "injected") + got[0] = "injected" if len(ss.GetRecentFiles()) != 1 { t.Error("returned slice shares internal backing storage") } diff --git a/backend/storage/json_store.go b/backend/storage/json_store.go index 5d94bd2..c1842cf 100644 --- a/backend/storage/json_store.go +++ b/backend/storage/json_store.go @@ -18,8 +18,6 @@ type JSONCampaignRepository struct { dir string } -// NewJSONCampaignRepository creates a repository rooted at dir (created if -// needed). func NewJSONCampaignRepository(dir string) (*JSONCampaignRepository, error) { if err := os.MkdirAll(dir, 0o755); err != nil { return nil, fmt.Errorf("failed to create campaign store: %w", err) @@ -34,7 +32,37 @@ func (r *JSONCampaignRepository) path(id string) (string, error) { return filepath.Join(r.dir, id+".json"), nil } -// Save writes a record atomically. +// Create writes a new record and fails if the ID already exists. +func (r *JSONCampaignRepository) Create(rec CampaignRecord) error { + r.mu.Lock() + defer r.mu.Unlock() + p, err := r.path(rec.ID) + if err != nil { + return err + } + if _, err := os.Stat(p); err == nil { + return fmt.Errorf("campaign record %q already exists", rec.ID) + } else if !os.IsNotExist(err) { + return err + } + return r.writeAtomic(p, rec) +} + +// Update atomically replaces an existing record. +func (r *JSONCampaignRepository) Update(rec CampaignRecord) error { + r.mu.Lock() + defer r.mu.Unlock() + p, err := r.path(rec.ID) + if err != nil { + return err + } + if _, err := os.Stat(p); err != nil { + return err + } + return r.writeAtomic(p, rec) +} + +// Save is an upsert-compatible persistence method retained for existing callers. func (r *JSONCampaignRepository) Save(rec CampaignRecord) error { r.mu.Lock() defer r.mu.Unlock() @@ -42,18 +70,44 @@ func (r *JSONCampaignRepository) Save(rec CampaignRecord) error { if err != nil { return err } + return r.writeAtomic(p, rec) +} + +func (r *JSONCampaignRepository) writeAtomic(path string, rec CampaignRecord) error { data, err := json.MarshalIndent(rec, "", " ") if err != nil { return err } - tmp := p + ".tmp" - if err := os.WriteFile(tmp, data, 0o644); err != nil { + tmp, err := os.CreateTemp(r.dir, filepath.Base(path)+".tmp-*") + if err != nil { return err } - return os.Rename(tmp, p) + tmpName := tmp.Name() + cleanup := func() { + _ = tmp.Close() + _ = os.Remove(tmpName) + } + if _, err := tmp.Write(data); err != nil { + cleanup() + return err + } + if err := tmp.Sync(); err != nil { + cleanup() + return err + } + if err := tmp.Close(); err != nil { + _ = os.Remove(tmpName) + return err + } + if err := os.Rename(tmpName, path); err != nil { + _ = os.Remove(tmpName) + return err + } + return nil } -// List returns all records, newest first. Corrupted files are skipped. +// List returns all records, newest first. Corrupted files are isolated and +// skipped so one bad record never prevents the rest of campaign history loading. func (r *JSONCampaignRepository) List() ([]CampaignRecord, error) { r.mu.Lock() defer r.mu.Unlock() @@ -76,11 +130,20 @@ func (r *JSONCampaignRepository) List() ([]CampaignRecord, error) { } out = append(out, rec) } - sort.Slice(out, func(i, j int) bool { return out[i].StartedAt.After(out[j].StartedAt) }) + sort.Slice(out, func(i, j int) bool { + li := out[i].StartedAt + lj := out[j].StartedAt + if li.IsZero() { + li = out[i].CreatedAt + } + if lj.IsZero() { + lj = out[j].CreatedAt + } + return li.After(lj) + }) return out, nil } -// Get returns a single record by ID. func (r *JSONCampaignRepository) Get(id string) (*CampaignRecord, error) { r.mu.Lock() defer r.mu.Unlock() @@ -99,7 +162,6 @@ func (r *JSONCampaignRepository) Get(id string) (*CampaignRecord, error) { return &rec, nil } -// Delete removes a record. func (r *JSONCampaignRepository) Delete(id string) error { r.mu.Lock() defer r.mu.Unlock() @@ -113,5 +175,4 @@ func (r *JSONCampaignRepository) Delete(id string) error { return nil } -// Compile-time check. var _ CampaignRepository = (*JSONCampaignRepository)(nil) diff --git a/backend/storage/json_store_test.go b/backend/storage/json_store_test.go index 5347694..9767c74 100644 --- a/backend/storage/json_store_test.go +++ b/backend/storage/json_store_test.go @@ -16,6 +16,7 @@ func TestJSONRepositorySaveListGet(t *testing.T) { ID: "run-1", StartedAt: time.Now().Add(-time.Minute), Subject: "Hello", + DraftOnly: true, RecipientCount: 3, State: campaign.CampaignCompleted, Result: campaign.CampaignResult{State: campaign.CampaignCompleted, Submitted: 3}, @@ -42,6 +43,9 @@ func TestJSONRepositorySaveListGet(t *testing.T) { if err != nil || got.Subject != "Hello" { t.Errorf("Get failed: %v %+v", err, got) } + if !got.DraftOnly { + t.Error("draft-only campaign mode was not preserved") + } if err := repo.Delete("run-1"); err != nil { t.Fatal(err) diff --git a/backend/storage/storage.go b/backend/storage/storage.go index e36a5b9..912ac83 100644 --- a/backend/storage/storage.go +++ b/backend/storage/storage.go @@ -2,9 +2,9 @@ Package storage defines persistence interfaces for campaign history and a JSON-backed implementation. -Per the remediation plan, SQLite is deferred; the application layer depends only -on these interfaces, so a SQLite (or other) implementation can replace the JSON -store later without rewriting callers. +SQLite remains a future implementation option; application code depends on this +repository interface so storage can evolve without coupling campaign execution to +a particular database. */ package storage @@ -12,25 +12,46 @@ import ( "time" "MailMergeApp/backend/campaign" + "MailMergeApp/backend/email" + "MailMergeApp/backend/models" ) -// CampaignRecord is a persisted snapshot of a completed (or terminal) campaign -// run: enough to review it, retry failed recipients, and export results. +// CampaignRecord is an immutable snapshot of one campaign run. It stores enough +// original input to review a campaign and reconstruct a safe retry after restart. +// Retries are written as new records linked by ParentCampaignID; the original +// record is never overwritten with a retry result. type CampaignRecord struct { - ID string `json:"id"` - StartedAt time.Time `json:"startedAt" ts_type:"string"` - FinishedAt time.Time `json:"finishedAt" ts_type:"string"` - Subject string `json:"subject"` - IsHTML bool `json:"isHTML"` - RecipientCount int `json:"recipientCount"` - State campaign.CampaignState `json:"state"` - Result campaign.CampaignResult `json:"result"` + ID string `json:"id"` + ParentCampaignID string `json:"parentCampaignId,omitempty"` + RunNumber int `json:"runNumber"` + CreatedAt time.Time `json:"createdAt" ts_type:"string"` + StartedAt time.Time `json:"startedAt" ts_type:"string"` + FinishedAt time.Time `json:"finishedAt" ts_type:"string"` + Duration time.Duration `json:"duration"` + Subject string `json:"subject"` + SubjectTemplate string `json:"subjectTemplate"` + BodyTemplate string `json:"bodyTemplate"` + IsHTML bool `json:"isHTML"` + DraftOnly bool `json:"draftOnly,omitempty"` + Headers []string `json:"headers,omitempty"` + Contacts []models.Contact `json:"contacts,omitempty"` + Attachments []string `json:"attachments,omitempty"` + CCTemplate string `json:"ccTemplate,omitempty"` + BCCTemplate string `json:"bccTemplate,omitempty"` + DuplicatePolicy email.Policy `json:"duplicatePolicy"` + SendOptions campaign.SendOptions `json:"sendOptions"` + SenderType string `json:"senderType"` + RecipientCount int `json:"recipientCount"` + State campaign.CampaignState `json:"state"` + Result campaign.CampaignResult `json:"result"` } -// CampaignRepository persists and retrieves campaign run records. +// CampaignRepository persists and retrieves campaign run snapshots. type CampaignRepository interface { - Save(rec CampaignRecord) error - List() ([]CampaignRecord, error) // newest first + Create(rec CampaignRecord) error + Update(rec CampaignRecord) error + Save(rec CampaignRecord) error // compatibility alias for upsert-style callers + List() ([]CampaignRecord, error) Get(id string) (*CampaignRecord, error) Delete(id string) error } diff --git a/dev_docs/release-checklist.md b/dev_docs/release-checklist.md new file mode 100644 index 0000000..742f705 --- /dev/null +++ b/dev_docs/release-checklist.md @@ -0,0 +1,74 @@ +# MailMerge Go Release Checklist + +Use this checklist for every release candidate and tagged release. A release is a **no-go** if any required item is incomplete. + +## 1. Source and change control + +- [ ] The release commit is on `main` and is associated with a reviewed pull request. +- [ ] Required CI checks are green: Go test/vet/lint, frontend test/build, dependency scan, SBOM generation, and Windows Wails build. +- [ ] `CHANGELOG.md` describes all user-visible, compatibility, security, and migration changes. +- [ ] `REMEDIATION_STATUS.md` does not contain an unresolved release blocker. +- [ ] The version follows semantic versioning and the release tag has the form `vX.Y.Z`. + +## 2. Automated validation + +- [ ] Run the **Release Candidate** workflow manually against the intended commit. +- [ ] Review backend and frontend coverage artifacts for unexpected regressions. +- [ ] Review the `govulncheck-report` artifact and confirm no reachable Go vulnerabilities. +- [ ] Confirm `npm audit --omit=dev --audit-level=high` passes. +- [ ] Confirm both CycloneDX SBOM files are generated and readable. +- [ ] Confirm the unsigned Windows release-candidate executable is produced with a SHA-256 checksum. + +## 3. Windows and Outlook compatibility + +Validate on a clean supported Windows environment: + +- [ ] Classic Outlook is installed and has at least one configured account. +- [ ] Outlook readiness reports a clear actionable result. +- [ ] Import a CSV and an XLSX contact list. +- [ ] Preview a personalized HTML and plain-text message. +- [ ] Create a test draft and send a test message. +- [ ] Run a small campaign with attachments. +- [ ] Cancel an active campaign and verify completed results are retained. +- [ ] Retry failed recipients from campaign history after restarting the application. +- [ ] Verify New Outlook-only systems are rejected with an actionable explanation. +- [ ] Verify clean shutdown while Outlook is open and after Outlook has been closed. + +## 4. Persistence and privacy + +- [ ] Settings, templates, suppression records, and campaign history reload after restart. +- [ ] Campaign retries preserve immutable original snapshots and parent/child run lineage. +- [ ] Delete-one and clear-all history controls behave as described. +- [ ] Exported logs contain only the intended recipient data. +- [ ] Local data locations and retention expectations are documented. +- [ ] No credentials, certificates, contact lists, generated history, or signing material are committed. + +## 5. Packaging and signing + +- [ ] Build metadata displays the version, commit, and build date. +- [ ] The release manifest matches the tag and commit. +- [ ] The executable is Authenticode-signed when signing secrets are configured. +- [ ] Signature verification succeeds on the final executable. +- [ ] `SHA256SUMS.txt` includes the executable, SBOMs, and release manifest. +- [ ] Install, upgrade, launch, repair, and uninstall are smoke-tested where an installer is produced. + +## 6. Publication and rollback + +- [ ] Tag the exact validated commit; do not rebuild from a different commit. +- [ ] Verify the GitHub release contains the executable, checksums, SBOMs, and release manifest. +- [ ] Review generated release notes before publishing. +- [ ] Preserve the previous stable release and its checksums for rollback. +- [ ] Record known limitations, especially the requirement for classic Outlook COM automation. +- [ ] Monitor the Security tab and issue tracker after publication. + +## Required branch protection + +Configure `main` to require these checks before merge: + +1. `Go (test, vet, lint)` +2. `Frontend (test, coverage, build)` +3. `Wails Windows build` +4. `Dependency scan` +5. `Generate SBOMs` + +Also require pull requests, prevent force pushes, dismiss stale approvals when code changes, and require the branch to be current before merging. diff --git a/dev_docs/release-evidence.md b/dev_docs/release-evidence.md new file mode 100644 index 0000000..32295cd --- /dev/null +++ b/dev_docs/release-evidence.md @@ -0,0 +1,13 @@ +# Release Evidence + +Each pull request and release candidate produces durable evidence that should be reviewed before publication: + +- `backend-coverage` — Go race-test coverage profile. +- `frontend-coverage` — Vitest HTML/JSON coverage output. +- `golangci-lint-diagnostics` — exact Go lint findings when the gate fails. +- `frontend-build-diagnostics` — TypeScript/Vite production compiler output. +- `govulncheck-report` — machine-readable reachable Go vulnerability findings. +- `cyclonedx-sboms` — CycloneDX SBOMs for Go and production frontend dependencies. +- `MailMergeApp-windows-unsigned` — CI-built Windows executable for smoke testing. + +The manual **Release Candidate** workflow additionally packages validation evidence and a checksum-protected Windows candidate without publishing a GitHub release. Review this evidence alongside `dev_docs/release-checklist.md` before creating a `v*` tag. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index afea3b1..0bb7530 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -119,7 +119,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -450,7 +449,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -499,11 +497,35 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } }, + "node_modules/@emnapi/core": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", + "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", @@ -1012,7 +1034,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/chai": { "version": "5.2.3", @@ -1071,7 +1094,6 @@ "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1083,7 +1105,6 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -1127,7 +1148,6 @@ "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.10", @@ -1272,6 +1292,7 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -1282,6 +1303,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -1371,7 +1393,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001803", @@ -1626,7 +1647,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dompurify": { "version": "3.4.12", @@ -2434,6 +2456,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -2629,7 +2652,6 @@ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2672,6 +2694,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -2730,7 +2753,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -2743,7 +2765,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -2757,7 +2778,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-quill": { "version": "2.0.0", @@ -3139,7 +3161,6 @@ "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", @@ -3218,7 +3239,6 @@ "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.1.10", "@vitest/mocker": "4.1.10", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 39188ae..086bc71 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -27,7 +27,7 @@ * cancellation/retry, keyboard shortcuts, and theme state. */ import { useState, useEffect, useCallback, useMemo } from 'react'; -import { Mail, AlertCircle, Send, FlaskConical, CheckCircle2, Sun, Moon, Eye, RefreshCw, Settings } from 'lucide-react'; +import { Mail, AlertCircle, Send, FlaskConical, CheckCircle2, Sun, Moon, Eye, Settings, History, Save } from 'lucide-react'; import { FileUpload, ContactTable, @@ -37,9 +37,13 @@ import { ResultsSummary, PreviewModal, TemplateManager, - SettingsModal + SettingsModal, + TestSendModal, + PreflightModal, + CampaignHistoryModal } from './components'; -import { ProgressUpdate, ParseResult, EmailTemplate, AppSettings } from './types'; +import { ProgressUpdate } from './types'; +import type { TestSendOptions } from './components'; import './styles/app.css'; // Import Wails bindings and models @@ -53,7 +57,11 @@ import { SendBulkEmails, PreflightCampaign, RetryFailed, + RetryCampaign, CancelCampaign, + GetCampaignHistory, + DeleteCampaign, + ClearCampaignHistory, GetMergeFields, GetMergeFieldsFromHeaders, PreviewMergeForContact, @@ -69,7 +77,7 @@ import { AddRecentFile, ClearRecentFiles } from '../wailsjs/go/main/App'; -import { models, campaign } from '../wailsjs/go/models'; +import { models, campaign, storage } from '../wailsjs/go/models'; import { EventsOn, EventsOff } from '../wailsjs/runtime/runtime'; // Type aliases for cleaner code @@ -77,6 +85,7 @@ type Contact = models.Contact; type CampaignResult = campaign.CampaignResult; type PreflightResult = campaign.PreflightResult; type FileInfo = models.FileInfo; +type CampaignRecord = storage.CampaignRecord; // Use Wails-generated types at the API boundary to avoid type drift. type WailsEmailTemplate = models.EmailTemplate; @@ -88,6 +97,22 @@ type WailsAppSettings = models.AppSettings; * Manages all application state and renders the mail merge interface. * Handles file selection, email composition, sending, and results display. */ +function normalizedWindowsPath(path: string): string { + return path.trim().replaceAll('/', '\\').toLocaleLowerCase(); +} + +function appendUniquePaths(existing: string[], additions: string[]): string[] { + const seen = new Set(existing.map(normalizedWindowsPath)); + const result = [...existing]; + for (const path of additions) { + const key = normalizedWindowsPath(path); + if (!key || seen.has(key)) continue; + seen.add(key); + result.push(path); + } + return result; +} + function App() { // ==================== State Management ==================== @@ -128,6 +153,15 @@ function App() { const [sendResult, setSendResult] = useState(null); const [preflight, setPreflight] = useState(null); const [lastRequest, setLastRequest] = useState(null); + const [draftOnly, setDraftOnly] = useState(false); + const [showTestSendModal, setShowTestSendModal] = useState(false); + const [showPreflightModal, setShowPreflightModal] = useState(false); + const [pendingRequest, setPendingRequest] = useState(null); + + // Campaign history state + const [showHistoryModal, setShowHistoryModal] = useState(false); + const [campaignHistory, setCampaignHistory] = useState([]); + const [isLoadingHistory, setIsLoadingHistory] = useState(false); // Error/status state const [error, setError] = useState(null); @@ -174,12 +208,14 @@ function App() { const toggleTheme = useCallback(() => { setTheme(prev => { const newTheme = prev === 'light' ? 'dark' : 'light'; - // Update settings with new theme - setSettings(s => ({ ...s, theme: newTheme })); - UpdateSettings({ ...settings, theme: newTheme }).catch(console.error); + setSettings(current => { + const next = new models.AppSettings({ ...current, theme: newTheme }); + UpdateSettings(next).catch(console.error); + return next; + }); return newTheme; }); - }, [settings]); + }, []); /** * Loads saved and built-in templates from the backend. @@ -370,7 +406,7 @@ function App() { try { const files = await SelectAttachments(); if (files && files.length > 0) { - setAttachments(prev => [...prev, ...files]); + setAttachments(prev => appendUniquePaths(prev, files)); } } catch (err: any) { setError(`Failed to add attachments: ${err.message || err}`); @@ -396,7 +432,7 @@ function App() { if (p) paths.push(p); } if (paths.length > 0) { - setAttachments(prev => [...prev, ...paths]); + setAttachments(prev => appendUniquePaths(prev, paths)); } }, []); @@ -443,40 +479,68 @@ function App() { [contacts, selectedIds] ); - const handleSendTestEmail = useCallback(async () => { - const testAddress = prompt('Enter test email address:'); - if (!testAddress) return; + const handleSendTestEmail = useCallback(() => { + if (contacts.length === 0) { + setError('Import at least one contact before sending a test message.'); + return; + } + setShowTestSendModal(true); + }, [contacts.length]); + const handleSubmitTestEmail = useCallback(async (options: TestSendOptions) => { try { setIsSending(true); setError(null); const parts = splitCcBcc(); - // Use the first selected contact (or first contact) so the test renders - // exactly like a real send, including custom fields. - const sample = selectedContacts[0] || contacts[0]; const request = new models.TestEmailRequest({ - testAddress, + testAddress: options.testAddress, subjectTemplate: subject, bodyTemplate: body, isHTML, attachments, ...parts, - contact: sample, - overwriteEmail: false, - sampleFirstName: sample?.firstName || 'John', - sampleLastName: sample?.lastName || 'Doe', + contact: options.contact, + overwriteEmail: options.overwriteEmail, + draftOnly: options.draftOnly, + sampleFirstName: options.contact.firstName || 'John', + sampleLastName: options.contact.lastName || 'Doe', }); await SendTestEmail(request); - setSuccessMessage(`Test email sent successfully to ${testAddress}`); + setShowTestSendModal(false); + setSuccessMessage(options.draftOnly + ? `Test draft created for ${options.testAddress}` + : `Test email sent successfully to ${options.testAddress}`); setTimeout(() => setSuccessMessage(null), 5000); } catch (err: any) { - setError(`Failed to send test email: ${err.message || err}`); + setError(`Failed to process test email: ${err.message || err}`); + } finally { + setIsSending(false); + } + }, [subject, body, isHTML, attachments, splitCcBcc]); + + const executeBulkSend = useCallback(async (request: models.EmailRequest) => { + try { + setIsSending(true); + setIsCancelling(false); + setError(null); + setProgress(null); + setProgressLogs([]); + setSendResult(null); + setLastRequest(request); + setShowPreflightModal(false); + + const result = await SendBulkEmails(request); + setSendResult(result); + } catch (err: any) { + setError(`Failed to process campaign: ${err.message || err}`); } finally { setIsSending(false); + setIsCancelling(false); + setPendingRequest(null); } - }, [subject, body, isHTML, attachments, selectedContacts, contacts, splitCcBcc]); + }, []); /** * Send to selected contacts through the backend campaign engine, which @@ -502,6 +566,7 @@ function App() { bodyTemplate: body, isHTML, attachments, + draftOnly, ...splitCcBcc(), }); @@ -519,59 +584,42 @@ function App() { return; } - // Honor the confirm-before-send setting. + setPendingRequest(request); if (settings.confirmSend) { - const warn = pf.warnings.length > 0 ? `\n\nWarnings:\n- ${pf.warnings.map((w) => w.message).join('\n- ')}` : ''; - const confirmed = window.confirm( - `Send ${pf.recipientCount} email(s)? This cannot be undone.${warn}` - ); - if (!confirmed) return; + setShowPreflightModal(true); + return; } + await executeBulkSend(request); + }, [selectedContacts, subject, body, isHTML, attachments, draftOnly, splitCcBcc, settings.confirmSend, executeBulkSend]); - try { - setIsSending(true); - setIsCancelling(false); - setError(null); - setProgress(null); - setProgressLogs([]); - setSendResult(null); - setLastRequest(request); - - const result = await SendBulkEmails(request); - setSendResult(result); - } catch (err: any) { - setError(`Failed to send emails: ${err.message || err}`); - } finally { - setIsSending(false); - setIsCancelling(false); - } - }, [selectedContacts, subject, body, isHTML, attachments, splitCcBcc, settings.confirmSend]); + const handleConfirmSend = useCallback(() => { + if (pendingRequest) void executeBulkSend(pendingRequest); + }, [pendingRequest, executeBulkSend]); /** * Retry only failed recipients via the backend, which appends attempts * without double-counting successes. */ const handleRetryFailed = useCallback(async () => { - if (!lastRequest || !sendResult || sendResult.failed === 0) { + if (!sendResult || sendResult.failed === 0 || (!sendResult.campaignId && !lastRequest)) { setError('No failed recipients to retry'); return; } - if (settings.confirmSend && !window.confirm(`Retry ${sendResult.failed} failed recipient(s)?`)) { - return; - } try { setIsSending(true); setError(null); setProgress(null); setProgressLogs([]); - const result = await RetryFailed(lastRequest); + const result = sendResult.campaignId + ? await RetryCampaign(sendResult.campaignId) + : await RetryFailed(lastRequest!); setSendResult(result); } catch (err: any) { setError(`Failed to retry emails: ${err.message || err}`); } finally { setIsSending(false); } - }, [lastRequest, sendResult, settings.confirmSend]); + }, [lastRequest, sendResult]); /** * Cancel the in-flight campaign. Unattempted recipients are marked cancelled. @@ -636,6 +684,9 @@ function App() { setDuplicates([]); setPreflight(null); setLastRequest(null); + setDraftOnly(false); + setPendingRequest(null); + setShowPreflightModal(false); }, []); /** @@ -759,6 +810,56 @@ function App() { } }, []); + const loadCampaignHistory = useCallback(async () => { + try { + setIsLoadingHistory(true); + setCampaignHistory(await GetCampaignHistory()); + } catch (err: any) { + setError(`Failed to load campaign history: ${err.message || err}`); + } finally { + setIsLoadingHistory(false); + } + }, []); + + const openCampaignHistory = useCallback(() => { + setShowHistoryModal(true); + void loadCampaignHistory(); + }, [loadCampaignHistory]); + + const handleHistoryRetry = useCallback(async (campaignId: string) => { + try { + setShowHistoryModal(false); + setIsSending(true); + setError(null); + setProgress(null); + setProgressLogs([]); + const result = await RetryCampaign(campaignId); + setSendResult(result); + } catch (err: any) { + setError(`Failed to retry campaign: ${err.message || err}`); + } finally { + setIsSending(false); + } + }, []); + + const handleHistoryDelete = useCallback(async (campaignId: string) => { + try { + await DeleteCampaign(campaignId); + await loadCampaignHistory(); + } catch (err: any) { + setError(`Failed to delete campaign: ${err.message || err}`); + } + }, [loadCampaignHistory]); + + const handleHistoryClear = useCallback(async () => { + try { + await ClearCampaignHistory(); + await loadCampaignHistory(); + } catch (err: any) { + setError(`Failed to clear campaign history: ${err.message || err}`); + } + }, [loadCampaignHistory]); + // Derive the selected-recipient count and send eligibility. const selectedCount = selectedIds.size; const canSend = selectedCount > 0 && subject.trim() && body.trim() && !isSending && outlookStatus === 'ok'; @@ -840,6 +941,14 @@ function App() {

+
-
+ +
{/* Preview button */} @@ -1068,6 +1187,39 @@ function App() { onPreview={handlePreviewEmail} /> + 0 ? selectedContacts : contacts} + subject={subject} + body={body} + isHTML={isHTML} + isSubmitting={isSending} + onClose={() => setShowTestSendModal(false)} + onPreview={handlePreviewEmail} + onSubmit={handleSubmitTestEmail} + /> + + total + info.size, 0)} + isSubmitting={isSending} + onClose={() => { setShowPreflightModal(false); setPendingRequest(null); }} + onConfirm={handleConfirmSend} + /> + + setShowHistoryModal(false)} + onRefresh={loadCampaignHistory} + onRetry={handleHistoryRetry} + onDelete={handleHistoryDelete} + onClear={handleHistoryClear} + /> + {/* Settings modal */} void; + onRefresh: () => void; + onRetry: (campaignId: string) => void; + onDelete: (campaignId: string) => void; + onClear: () => void; +} + +export function CampaignHistoryModal({ + isOpen, + records, + isLoading, + onClose, + onRefresh, + onRetry, + onDelete, + onClear, +}: CampaignHistoryModalProps) { + const [confirmClear, setConfirmClear] = useState(false); + if (!isOpen) return null; + return ( +
{ + if (event.target === event.currentTarget) onClose(); + }}> +
+
+

Campaign history

+ +
+
+
+ + {confirmClear ? ( + + Delete all history? + + + + ) : ( + + )} +
+ {isLoading ?

Loading campaign history…

: records.length === 0 ? ( +

No campaign runs have been recorded.

+ ) : ( +
+ {records.map((record) => ( +
+
+ {record.subject || '(No subject)'} + {new Date(record.startedAt || record.createdAt).toLocaleString()} · Run {record.runNumber || 1} + {record.recipientCount} recipient(s) · {record.state.replaceAll('_', ' ')} +
+
+ {record.result?.failed > 0 && } + +
+
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..0b591ca --- /dev/null +++ b/frontend/src/components/ErrorBoundary.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { AlertTriangle, RefreshCw } from 'lucide-react'; + +interface ErrorBoundaryProps { + children?: React.ReactNode; +} + +interface ErrorBoundaryState { + hasError: boolean; + message: string; +} + +export class ErrorBoundary extends React.Component { + state: ErrorBoundaryState = { hasError: false, message: '' }; + + static getDerivedStateFromError(error: unknown): ErrorBoundaryState { + return { + hasError: true, + message: error instanceof Error ? error.message : 'An unexpected interface error occurred.', + }; + } + + componentDidCatch(error: unknown, info: React.ErrorInfo) { + console.error('MailMerge Go UI error', error, info.componentStack); + } + + render() { + if (!this.state.hasError) return this.props.children; + return ( +
+ +

MailMerge Go could not display this screen

+

{this.state.message}

+ +
+ ); + } +} diff --git a/frontend/src/components/PreflightModal.tsx b/frontend/src/components/PreflightModal.tsx new file mode 100644 index 0000000..3ba2df2 --- /dev/null +++ b/frontend/src/components/PreflightModal.tsx @@ -0,0 +1,91 @@ +import { AlertCircle, AlertTriangle, Clock, Paperclip, Save, Send, Users, X } from 'lucide-react'; +import type { campaign } from '../../wailsjs/go/models'; + +interface PreflightModalProps { + isOpen: boolean; + result: campaign.PreflightResult | null; + draftOnly: boolean; + totalAttachmentBytes: number; + isSubmitting: boolean; + onClose: () => void; + onConfirm: () => void; +} + +function formatBytes(bytes: number): string { + if (bytes <= 0) return 'None'; + const units = ['B', 'KB', 'MB', 'GB']; + const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); + return `${(bytes / 1024 ** index).toFixed(index === 0 ? 0 : 1)} ${units[index]}`; +} + +function formatDuration(nanoseconds: number): string { + const seconds = Math.max(0, Math.round((nanoseconds || 0) / 1_000_000_000)); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + return `${minutes}m ${seconds % 60}s`; +} + +export function PreflightModal({ + isOpen, + result, + draftOnly, + totalAttachmentBytes, + isSubmitting, + onClose, + onConfirm, +}: PreflightModalProps) { + if (!isOpen || !result) return null; + + return ( +
{ + if (event.target === event.currentTarget && !isSubmitting) onClose(); + }}> +
+
+

Review campaign

+ +
+
+
+
{result.recipientCount}Recipients
+
{formatDuration(result.estimatedDuration)}Estimated
+
{formatBytes(totalAttachmentBytes)}Attachments
+
+ + {result.errors.length > 0 && ( +
+

Blocking issues

+
    {result.errors.map((issue, index) =>
  • {issue.message}
  • )}
+
+ )} + {result.warnings.length > 0 && ( +
+

Warnings

+
    {result.warnings.map((issue, index) =>
  • {issue.message}
  • )}
+
+ )} + {result.errors.length === 0 && result.warnings.length === 0 && ( +
Preflight passed with no warnings.
+ )} + +
+ {draftOnly ? : } +
+ {draftOnly ? 'Draft-only mode' : 'Send mode'} + {draftOnly ? 'Messages will be saved in Outlook Drafts and will not be sent.' : 'Outlook will submit messages immediately after confirmation.'} +
+
+
+
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/TestSendModal.tsx b/frontend/src/components/TestSendModal.tsx new file mode 100644 index 0000000..cd55536 --- /dev/null +++ b/frontend/src/components/TestSendModal.tsx @@ -0,0 +1,187 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import DOMPurify from 'dompurify'; +import { FlaskConical, Save, Send, X } from 'lucide-react'; +import type { Contact } from '../types'; + +export interface TestSendOptions { + testAddress: string; + contact: Contact; + overwriteEmail: boolean; + draftOnly: boolean; +} + +interface TestSendModalProps { + isOpen: boolean; + contacts: Contact[]; + subject: string; + body: string; + isHTML: boolean; + isSubmitting: boolean; + onClose: () => void; + onPreview: (contact: Contact) => Promise<{ subject: string; body: string } | null>; + onSubmit: (options: TestSendOptions) => Promise; +} + +export function TestSendModal({ + isOpen, + contacts, + subject, + body, + isHTML, + isSubmitting, + onClose, + onPreview, + onSubmit, +}: TestSendModalProps) { + const [testAddress, setTestAddress] = useState(''); + const [selectedIndex, setSelectedIndex] = useState(0); + const [overwriteEmail, setOverwriteEmail] = useState(false); + const [draftOnly, setDraftOnly] = useState(false); + const [previewSubject, setPreviewSubject] = useState(subject); + const [previewBody, setPreviewBody] = useState(body); + const [previewLoading, setPreviewLoading] = useState(false); + const [validationError, setValidationError] = useState(''); + const addressRef = useRef(null); + + const selectedContact = useMemo( + () => contacts[selectedIndex] || contacts[0], + [contacts, selectedIndex], + ); + + useEffect(() => { + if (!isOpen) return; + setSelectedIndex(0); + setOverwriteEmail(false); + setDraftOnly(false); + setValidationError(''); + window.setTimeout(() => addressRef.current?.focus(), 0); + }, [isOpen]); + + useEffect(() => { + if (!isOpen || !selectedContact) { + setPreviewSubject(subject); + setPreviewBody(body); + return; + } + let cancelled = false; + setPreviewLoading(true); + onPreview(selectedContact) + .then((result) => { + if (cancelled) return; + setPreviewSubject(result?.subject ?? subject); + setPreviewBody(result?.body ?? body); + }) + .catch(() => { + if (cancelled) return; + setPreviewSubject(subject); + setPreviewBody(body); + }) + .finally(() => { + if (!cancelled) setPreviewLoading(false); + }); + return () => { cancelled = true; }; + }, [isOpen, selectedContact, subject, body, onPreview]); + + useEffect(() => { + if (!isOpen) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape' && !isSubmitting) onClose(); + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [isOpen, isSubmitting, onClose]); + + if (!isOpen) return null; + + const submit = async () => { + const address = testAddress.trim(); + if (!/^\S+@\S+\.\S+$/.test(address)) { + setValidationError('Enter a valid test email address.'); + addressRef.current?.focus(); + return; + } + if (!selectedContact) { + setValidationError('Import or select a contact to provide merge data.'); + return; + } + setValidationError(''); + await onSubmit({ testAddress: address, contact: selectedContact, overwriteEmail, draftOnly }); + }; + + return ( +
{ + if (event.target === event.currentTarget && !isSubmitting) onClose(); + }}> +
+
+

Test message

+ +
+
+
+ + setTestAddress(event.target.value)} + placeholder="you@example.com" + autoComplete="email" + /> + + + + + + + {validationError &&
{validationError}
} +
+ +
+
Rendered preview
+ {previewLoading ? ( +
Rendering…
+ ) : ( + <> +
Subject: {previewSubject}
+ {isHTML ? ( +
+ ) : ( +
{previewBody}
+ )} + + )} +
+
+
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index d728d45..4863811 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -1,11 +1,3 @@ -/** - * Components Index - Central Export for UI Components - * - * This barrel file exports all reusable UI components used in the MailMerge application. - * Import components from this file for cleaner imports throughout the application. - * - * Example: import { FileUpload, ContactTable, EmailEditor } from './components'; - */ export { FileUpload } from './FileUpload'; export { ContactTable } from './ContactTable'; export { EmailEditor } from './EmailEditor'; @@ -15,3 +7,8 @@ export { ResultsSummary } from './ResultsSummary'; export { PreviewModal } from './PreviewModal'; export { TemplateManager } from './TemplateManager'; export { SettingsModal } from './SettingsModal'; +export { TestSendModal } from './TestSendModal'; +export type { TestSendOptions } from './TestSendModal'; +export { PreflightModal } from './PreflightModal'; +export { CampaignHistoryModal } from './CampaignHistoryModal'; +export { ErrorBoundary } from './ErrorBoundary'; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index caadbe8..973823d 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -11,6 +11,7 @@ import React from 'react' import {createRoot} from 'react-dom/client' import App from './App' +import {ErrorBoundary} from './components' // Get the root DOM element const container = document.getElementById('root') @@ -21,6 +22,8 @@ const root = createRoot(container!) // Render the application with StrictMode for development checks root.render( - + + + ) diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 34b2555..0d49639 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -1239,3 +1239,253 @@ body { white-space: nowrap; border: 0; } + +/* Release hardening dialogs and history */ +.test-send-modal, +.preflight-modal { + max-width: 880px; +} + +.history-modal { + max-width: 820px; +} + +.modal-grid { + display: grid; + grid-template-columns: minmax(260px, 0.8fr) minmax(320px, 1.2fr); + gap: 1.25rem; +} + +.modal-form-column { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.form-label { + color: var(--text-primary); + font-weight: 600; + font-size: 0.875rem; +} + +.inline-error { + padding: 0.65rem 0.75rem; + border: 1px solid var(--danger); + border-radius: var(--border-radius); + background: color-mix(in srgb, var(--danger) 10%, transparent); + color: var(--danger); + font-size: 0.875rem; +} + +.test-preview { + min-height: 260px; + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + background: var(--bg-secondary); + overflow: auto; +} + +.test-preview-label { + padding: 0.65rem 0.85rem; + border-bottom: 1px solid var(--border-color); + color: var(--text-secondary); + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.test-preview-subject, +.test-preview-body, +.test-preview-loading { + padding: 0.85rem; +} + +.test-preview-subject { + border-bottom: 1px solid var(--border-color); +} + +.test-preview-body { + color: var(--text-primary); + overflow-wrap: anywhere; +} + +.test-preview-plain { + white-space: pre-wrap; + font-family: inherit; + margin: 0; +} + +.preflight-stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.75rem; + margin-bottom: 1rem; +} + +.preflight-stats > div { + display: grid; + justify-items: center; + gap: 0.2rem; + padding: 0.9rem; + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + background: var(--bg-secondary); +} + +.preflight-stats span { + color: var(--text-muted); + font-size: 0.75rem; +} + +.preflight-section { + margin-top: 1rem; + padding: 0.9rem 1rem; + border-radius: var(--border-radius); +} + +.preflight-section h4 { + display: flex; + align-items: center; + gap: 0.4rem; + margin: 0 0 0.5rem; +} + +.preflight-section ul { + margin: 0; + padding-left: 1.25rem; +} + +.preflight-errors { + border: 1px solid var(--danger); + background: color-mix(in srgb, var(--danger) 8%, transparent); +} + +.preflight-warnings { + border: 1px solid var(--warning); + background: color-mix(in srgb, var(--warning) 10%, transparent); +} + +.preflight-ready { + padding: 0.8rem; + border-radius: var(--border-radius); + background: color-mix(in srgb, var(--success) 10%, transparent); + color: var(--success); + font-weight: 600; +} + +.preflight-mode { + display: flex; + gap: 0.75rem; + align-items: flex-start; + margin-top: 1rem; + padding: 0.85rem; + border: 1px solid var(--border-color); + border-radius: var(--border-radius); +} + +.preflight-mode div { + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.preflight-mode span { + color: var(--text-secondary); + font-size: 0.85rem; +} + +.send-mode-option { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 1rem; + padding: 0.8rem; + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + cursor: pointer; +} + +.history-actions, +.history-row, +.history-row-actions { + display: flex; + align-items: center; +} + +.history-actions { + justify-content: space-between; + margin-bottom: 1rem; +} + +.history-list { + display: flex; + flex-direction: column; + gap: 0.65rem; +} + +.history-row { + justify-content: space-between; + gap: 1rem; + padding: 0.85rem; + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + background: var(--bg-secondary); +} + +.history-row-main { + display: flex; + flex-direction: column; + min-width: 0; + gap: 0.2rem; +} + +.history-row-main strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.history-row-main span { + color: var(--text-secondary); + font-size: 0.78rem; + text-transform: capitalize; +} + +.history-row-actions { + gap: 0.5rem; + flex-shrink: 0; +} + +.fatal-error { + min-height: 100vh; + display: grid; + place-content: center; + justify-items: center; + gap: 0.8rem; + padding: 2rem; + text-align: center; + background: var(--bg-primary); + color: var(--text-primary); +} + +@media (max-width: 760px) { + .modal-grid, + .preflight-stats { + grid-template-columns: 1fr; + } + + .history-row { + align-items: flex-start; + flex-direction: column; + } +} + +.history-clear-confirm { + display: flex; + align-items: center; + gap: 0.45rem; + color: var(--danger); + font-size: 0.8rem; + font-weight: 600; +} diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 07f4cb0..28bd0c7 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -11,8 +11,12 @@ export function CancelCampaign():Promise; export function CheckOutlookInstalled():Promise; +export function ClearCampaignHistory():Promise; + export function ClearRecentFiles():Promise; +export function DeleteCampaign(arg1:string):Promise; + export function DeleteTemplate(arg1:string):Promise; export function ExportLogsToCSV(arg1:Array):Promise; @@ -21,6 +25,8 @@ export function GetAllTemplates():Promise>; export function GetAppInfo():Promise>; +export function GetCampaign(arg1:string):Promise; + export function GetCampaignHistory():Promise>; export function GetFileInfo(arg1:string):Promise; @@ -49,6 +55,8 @@ export function PreviewMerge(arg1:string,arg2:string,arg3:string,arg4:string,arg export function PreviewMergeForContact(arg1:string,arg2:string,arg3:models.Contact):Promise; +export function RetryCampaign(arg1:string):Promise; + export function RetryFailed(arg1:models.EmailRequest):Promise; export function SaveTemplate(arg1:models.EmailTemplate):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 0bca397..68b3476 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -14,10 +14,18 @@ export function CheckOutlookInstalled() { return window['go']['main']['App']['CheckOutlookInstalled'](); } +export function ClearCampaignHistory() { + return window['go']['main']['App']['ClearCampaignHistory'](); +} + export function ClearRecentFiles() { return window['go']['main']['App']['ClearRecentFiles'](); } +export function DeleteCampaign(arg1) { + return window['go']['main']['App']['DeleteCampaign'](arg1); +} + export function DeleteTemplate(arg1) { return window['go']['main']['App']['DeleteTemplate'](arg1); } @@ -34,6 +42,10 @@ export function GetAppInfo() { return window['go']['main']['App']['GetAppInfo'](); } +export function GetCampaign(arg1) { + return window['go']['main']['App']['GetCampaign'](arg1); +} + export function GetCampaignHistory() { return window['go']['main']['App']['GetCampaignHistory'](); } @@ -90,6 +102,10 @@ export function PreviewMergeForContact(arg1, arg2, arg3) { return window['go']['main']['App']['PreviewMergeForContact'](arg1, arg2, arg3); } +export function RetryCampaign(arg1) { + return window['go']['main']['App']['RetryCampaign'](arg1); +} + export function RetryFailed(arg1) { return window['go']['main']['App']['RetryFailed'](arg1); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index abedacf..c5ef9f7 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -4,6 +4,7 @@ export namespace campaign { number: number; status: string; error?: string; + trigger?: string; timestamp: string; static createFrom(source: any = {}) { @@ -15,6 +16,7 @@ export namespace campaign { this.number = source["number"]; this.status = source["status"]; this.error = source["error"]; + this.trigger = source["trigger"]; this.timestamp = source["timestamp"]; } } @@ -174,8 +176,34 @@ export namespace campaign { return a; } } + export class SendOptions { + delayBetweenMessages: number; + batchSize: number; + pauseBetweenBatches: number; + confirmBeforeSend: boolean; + continueOnError: boolean; + + static createFrom(source: any = {}) { + return new SendOptions(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.delayBetweenMessages = source["delayBetweenMessages"]; + this.batchSize = source["batchSize"]; + this.pauseBetweenBatches = source["pauseBetweenBatches"]; + this.confirmBeforeSend = source["confirmBeforeSend"]; + this.continueOnError = source["continueOnError"]; + } + } export class CampaignResult { + campaignId?: string; + parentCampaignId?: string; + runNumber?: number; state: string; + startedAt: string; + finishedAt: string; + duration: number; attempted: number; submitted: number; failed: number; @@ -191,7 +219,13 @@ export namespace campaign { constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); + this.campaignId = source["campaignId"]; + this.parentCampaignId = source["parentCampaignId"]; + this.runNumber = source["runNumber"]; this.state = source["state"]; + this.startedAt = source["startedAt"]; + this.finishedAt = source["finishedAt"]; + this.duration = source["duration"]; this.attempted = source["attempted"]; this.submitted = source["submitted"]; this.failed = source["failed"]; @@ -328,6 +362,7 @@ export namespace models { bcc?: string; ccTemplate?: string; bccTemplate?: string; + draftOnly?: boolean; static createFrom(source: any = {}) { return new EmailRequest(source); @@ -344,6 +379,7 @@ export namespace models { this.bcc = source["bcc"]; this.ccTemplate = source["ccTemplate"]; this.bccTemplate = source["bccTemplate"]; + this.draftOnly = source["draftOnly"]; } convertValues(a: any, classs: any, asMap: boolean = false): any { @@ -460,6 +496,7 @@ export namespace models { sampleLastName: string; contact: Contact; overwriteEmail: boolean; + draftOnly?: boolean; static createFrom(source: any = {}) { return new TestEmailRequest(source); @@ -480,6 +517,7 @@ export namespace models { this.sampleLastName = source["sampleLastName"]; this.contact = this.convertValues(source["contact"], Contact); this.overwriteEmail = source["overwriteEmail"]; + this.draftOnly = source["draftOnly"]; } convertValues(a: any, classs: any, asMap: boolean = false): any { @@ -526,10 +564,25 @@ export namespace storage { export class CampaignRecord { id: string; + parentCampaignId?: string; + runNumber: number; + createdAt: string; startedAt: string; finishedAt: string; + duration: number; subject: string; + subjectTemplate: string; + bodyTemplate: string; isHTML: boolean; + draftOnly?: boolean; + headers?: string[]; + contacts?: models.Contact[]; + attachments?: string[]; + ccTemplate?: string; + bccTemplate?: string; + duplicatePolicy: string; + sendOptions: campaign.SendOptions; + senderType: string; recipientCount: number; state: string; result: campaign.CampaignResult; @@ -541,26 +594,36 @@ export namespace storage { constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); this.id = source["id"]; + this.parentCampaignId = source["parentCampaignId"]; + this.runNumber = source["runNumber"]; + this.createdAt = source["createdAt"]; this.startedAt = source["startedAt"]; this.finishedAt = source["finishedAt"]; + this.duration = source["duration"]; this.subject = source["subject"]; + this.subjectTemplate = source["subjectTemplate"]; + this.bodyTemplate = source["bodyTemplate"]; this.isHTML = source["isHTML"]; + this.draftOnly = source["draftOnly"]; + this.headers = source["headers"]; + this.contacts = this.convertValues(source["contacts"], models.Contact); + this.attachments = source["attachments"]; + this.ccTemplate = source["ccTemplate"]; + this.bccTemplate = source["bccTemplate"]; + this.duplicatePolicy = source["duplicatePolicy"]; + this.sendOptions = this.convertValues(source["sendOptions"], campaign.SendOptions); + this.senderType = source["senderType"]; this.recipientCount = source["recipientCount"]; this.state = source["state"]; this.result = this.convertValues(source["result"], campaign.CampaignResult); } convertValues(a: any, classs: any, asMap: boolean = false): any { - if (!a) { - return a; - } - if (a.slice && a.map) { - return (a as any[]).map(elem => this.convertValues(elem, classs)); - } else if ("object" === typeof a) { + if (!a) return a; + if (a.slice && a.map) return (a as any[]).map(elem => this.convertValues(elem, classs)); + if ("object" === typeof a) { if (asMap) { - for (const key of Object.keys(a)) { - a[key] = new classs(a[key]); - } + for (const key of Object.keys(a)) a[key] = new classs(a[key]); return a; } return new classs(a);