# MassMailer architecture (draft) ## Component map ```mermaid flowchart LR O[Owner] --> L[Launcher and database preflight] L --> U[32-bit HTA UI] U --> C[Campaign and contact services] U --> D[In-app dispatch timer] C --> M[(Access MDB)] D --> M D --> E[Eligibility and pacing policy] E --> A[SMTP adapter] A --> P[SMTP server] ``` The HTA owns campaign editing, preview, approval, pause, reporting, and dispatch. Sending is possible only while the HTA is open and the owner has explicitly started or resumed a campaign. Closing the window starts no further sends. On reopening, all campaigns remain paused until the owner explicitly resumes them. The timer is only a wake-up mechanism; durable database state enforces pacing. A single-instance guard prevents two HTAs from dispatching at once. Keep business rules separate from UI DOM code so they can be tested with a fake clock and SMTP adapter. ## Launch and provider check The launcher reads `DB_PATH`, `DB_PROVIDER`, and `DB_BITNESS` from the INI, validates them, and starts the configured process architecture. On the current PC, the 32-bit host with `Microsoft.ACE.OLEDB.12.0` passed the temporary `.mdb` create/write/read probe; 64-bit hosts failed. The deployment launcher must therefore run the 32-bit `mshta.exe` on this PC. It must open the **configured** `.mdb` using the configured provider from the same architecture that will host the HTA, read `AppMeta.schemaVersion`, and fail with a clear error if the provider, file, permissions, or schema is wrong. The temporary diagnostic is useful on another PC but is not a substitute for this startup check. Do not silently fall back to another provider or create a new production database when opening fails. ## Campaign and dispatch flow 1. Import CSV contacts with source reference and collection date. Normalize addresses for case-insensitive matching while retaining the original value. Keep a global suppression list that re-import cannot clear. 2. Import a reusable HTML template and local images. Approval freezes the template, image bytes, recipient set, sender and reply identity, postal address, and subject in a campaign version. Create one `Delivery` per eligible address. 3. The owner explicitly starts or resumes a campaign. While the HTA remains open, the timer checks the global pause flag, campaign start instant, `nextAllowedAttemptUtc`, and pending deliveries. It attempts at most one recipient at a time. Recheck suppression immediately before contacting SMTP. 4. In a transaction, mark an `Attempt` as `Started` before the provider call. Record a definitive `Accepted` or `Failed` response afterward. If the outcome is unknown after a timeout or crash, mark it `Uncertain` on recovery and require manual reconciliation. SMTP acceptance does not prove inbox delivery. 5. Reserve the next account-wide slot after **every** attempt, including failures and retries: `nextAllowedAttemptUtc = max(now + interval, retryBackoffUntil)`. Missed slots never accumulate. On app close or restart, no new send begins and no automatic resume occurs. 6. Show the next eligible time, outcomes, errors, and opt-out review reminder. The owner checks the monitored reply mailbox daily and records opt-outs; the next dispatch checks suppression again. A synchronous COM SMTP call may briefly block the HTA. The feasibility spike must measure that behavior, provide a timeout and visible sending state, and determine whether a small helper process is needed for responsiveness. Any helper must remain bound to the open HTA session and must stop accepting new work when it closes. ## Access MDB data model | Entity | Essential fields | | --- | --- | | AppMeta | schemaVersion, createdAt, migratedAt | | Contact | id, emailOriginal, emailKey, businessName, sourceReference, acquiredAt, country, eligibilityBasis | | Suppression | emailKey, reason, recordedAt, evidenceReference | | TemplateVersion | id, templateName, version, htmlContent, contentHash, importedAt, validationStatus | | TemplateImage | id, templateVersionId, originalRelativePath, contentType, contentHash, snapshotBytes, contentId | | Campaign | id, name, senderKey, fromSnapshot, replyToSnapshot, postalAddressSnapshot, subject, templateVersionId, status, startAtUtc, approvedAt | | Delivery | id, campaignId, contactId, emailKey, status, attemptCount, lastAttemptAt, providerMessageId, lastErrorCode | | Attempt | id, deliveryId, startedAtUtc, endedAtUtc, outcome, providerRequestId, sanitizedError | | DispatchState | senderKey, lastAttemptAtUtc, nextAllowedAttemptUtc | | OperationalState | id, globalPause, lastOptOutReviewAtUtc, lastConfigHash | | ProtectedSecret | id, purpose, dpapiCiphertext, createdAt, rotatedAt | | AuditEvent | id, actor, action, entityId, timestampUtc, detail | Use ADO parameterized commands for values and transactions for state transitions. Create unique indexes for `Contact.emailKey`, `Suppression.emailKey`, and `(Delivery.campaignId, Delivery.emailKey)`. Index due deliveries and attempt history. Use one local MDB and one active HTA instance; do not put the database on a network share. Restrict file permissions to the operator. Back up the MDB, INI, and template sources together while the app is closed, then test restore. The MDB, lock files, backups, and live configuration are excluded from Git. ## HTML templates with local images The owner selects a local `.html` file. Image references must be relative to that file's folder, such as `images/postcard.jpg`. On import, resolve each path within that folder, reject traversal and absolute, remote, or data URLs, validate PNG/JPEG/GIF content and size, and snapshot bytes into `TemplateImage`. The preview uses the copied assets in a restricted context so template scripts cannot run in the privileged HTA. The mail adapter replaces each image reference with a `cid:` URL and builds an HTML `multipart/related` message with matching inline image parts. Recipient HTML must contain no `file://` or drive paths. General attachments are outside v1. The SMTP adapter uses the built-in 32-bit Windows PowerShell/.NET Framework `System.Net.Mail` APIs. A local capability probe completed a certificate-validated STARTTLS 1.3 handshake, and a pickup-folder test produced HTML `multipart/related` MIME with a CID-linked image. The explicit one-message test uses `SmtpClient.EnableSsl` and the configured SMTP credentials. Password authentication and received-message rendering still require the owner to enter the password locally and inspect the test inbox. The queued campaign path will use the same MIME construction after template snapshot and dispatch controls are built. ## Configuration and credentials The validated `config.ini` is the source of truth for system-wide settings described in `config.example.ini`. The MDB is the source of truth for campaign data and runtime state. The owner may edit INI values directly or through the HTA; the app validates changes before dispatch, records a configuration audit event, and never starts or resumes sending because of a config edit. Do not duplicate an editable setting in both places. | INI setting group | Meaning | MDB counterpart | | --- | --- | --- | | `SMTP_*`, `SMTP_SECRET_ID` | Endpoint, auth identity, required TLS/STARTTLS, from and reply addresses, internal test recipient, secret lookup | `ProtectedSecret` stores DPAPI ciphertext and rotation metadata. | | `DB_PATH`, `DB_PROVIDER`, `DB_BITNESS` | MDB location, OLE DB provider, and launch architecture | `AppMeta` stores schema version. | | `QUEUE_INTERVAL`, `SEND_MODE`, `DISPLAY_TIME_ZONE` | Account-wide minimum gap, allowed sending hours, display time | `DispatchState` stores pacing; `OperationalState` stores pause and review state. `SEND_MODE=always` means any hour **while the HTA is open**. | | `TEMPLATE_ROOT`, `TEMPLATE_IMAGE_*`, `TEMPLATE_MESSAGE_MAX_BYTES` | Source folder, inline image mode, supported types, size limits | Template and image versions store approved snapshots. | | `OPT_OUT_REVIEW_HOURS`, `BUSINESS_POSTAL_ADDRESS` | Review reminder and required footer for new campaigns | Review timestamp and campaign footer snapshot. | `SMTP_TLS=on` means encryption is mandatory; `SMTP_STARTTLS=on` means the client must negotiate STARTTLS before authentication or message data. Port 587 is configurable. Invalid combinations, handshake failure, or invalid certificates block sending. `QUEUE_INTERVAL=300` means at least 300 seconds between attempts from this account across all campaigns. On interval change, use `max(persistedNextAllowedUtc, lastAttemptAtUtc + currentInterval)` so a new value cannot shorten the reserved gap. The HTA starts a local interactive x86 PowerShell setup action. `Read-Host -AsSecureString` accepts the password without writing it to a command line, file, or log; `ConvertFrom-SecureString` uses Windows DPAPI for the current user. Only its ciphertext is stored in `ProtectedSecret`. The sender reads it with `ConvertTo-SecureString` and passes the secure value to `NetworkCredential`. Save, load, and rotation passed a disposable-MDB test with synthetic passwords. A backup restored under another Windows identity requires password re-entry. Password setup is allowed while the HTA is closed because it does not send mail. ## Open technical decisions | Area | Decision to prove in Sprint 0 | | --- | --- | | SMTP | STARTTLS and local inline MIME pass. Verify actual password authentication, received image rendering, size caps, and marketing policy. | | MDB | Choose migration and backup procedure; verify the real startup gate with the 32-bit HTA and configured file. | | UI responsiveness | Measure synchronous send duration and choose a bounded helper if the HTA blocks too long. | | DPAPI | Synthetic password save/load/rotation passes. Verify the owner's password works for a live internal test. | ## Technical source notes - Microsoft Access connectivity and Jet architecture: https://learn.microsoft.com/en-us/sql/connect/connect-history - Access Runtime and MDB support: https://support.microsoft.com/en-us/access/download-and-install-microsoft-365-access-runtime - Provider bitness matching: https://learn.microsoft.com/en-us/troubleshoot/power-platform/power-automate/desktop-flows/cannot-connect-access-database-engine-ole-db - DPAPI CurrentUser: https://learn.microsoft.com/en-us/dotnet/api/system.security.cryptography.dataprotectionscope - CID MIME references: https://www.rfc-editor.org/info/rfc2387/ and https://www.rfc-editor.org/info/rfc2392/ - .NET SMTP STARTTLS behavior: https://learn.microsoft.com/en-us/dotnet/api/system.net.mail.smtpclient.enablessl - Windows PowerShell secure-string DPAPI behavior: https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.security/convertfrom-securestring