5ecdf7cee8 · feat: document mailbox sync fair use: a What gets synced section on the mailboxes guide covering the initial import (window, cap, newest first, folders read and skipped, the drawer's Sync card), live sync, the per-mailbox and per-organization budgets with deferral instead of dropping and the priority given to outreach replies, and the two patterns that deactivate a mailbox; the four sync.* instance settings on the configuration page; GET /emails/:id/sync in the endpoint scope map and the OpenAPI spec with MailboxSync, MailboxSyncState and MailboxSyncPolicy schemas; SYNC_STATE on the events page and ACCOUNT_SYNC_STATE on the realtime page; the architecture anti-abuse list pointing at the governor instead of the removed ratelimit.go; and the AGENTS.md worker-side abuse detection section rewritten around the governor's lanes, deferral, escalation rules and relayed state · Updated 2026-08-18 15:43:47 +00:00
94477efd5f · feat: add an 'interaction details are part of done' checklist to the dashboard UI conventions in AGENTS.md covering that every dropdown must close on click-away and Escape even inside a dialog (capture-phase click-outside listeners because dialog cards stop mousedown propagation, and Escape closes only the innermost layer by bailing while a data-floating popover or the alertdialog confirm is on screen), that switches are the shared Toggle with the double-toggle traps spelled out, that every detail page needs a route back on all viewports since the header breadcrumb is desktop-only, that multi-step flows animate, explain blocked steps, refuse to skip ahead and confirm before discarding, and that a control whose action cannot succeed must not ship, after the campaign wizard shipped with all of these wrong · Updated 2026-08-18 14:42:07 +00:00
533a9ca0a2 · feat: stop a freshly connected mailbox being silently excluded from every campaign send, by making an unset mailbox timezone representable as the empty string the campaign scheduler already checks for, since email_accounts.timezone defaulted to 'UTC' while campaigns.timezone defaults to 'Europe/London' and nothing in the OAuth or SMTP onboarding paths ever set either, so a brand new mailbox looked deliberately placed in UTC, was compared against the differing campaign zone and dropped by the hardcoded 8am-8pm business-hours gate whenever the current UTC hour fell outside it, emptying the candidate pool and failing the campaign start, adding a migration that changes the column default and converts existing 'UTC' rows because until now no API field, dashboard control or onboarding path could set that column at all so every such row is the old default rather than a choice, adding the missing Timezone field to UpdateEmail with IANA validation so the setting the sending-behaviour UI already tells people to change is finally reachable and an unloadable zone is rejected instead of being silently coerced to UTC by the scheduler, and replacing the misleading 'no active email accounts found for campaign's email tags' response for a pool that exists but is entirely gated out with a distinct message naming the real cause, via an ErrNoEligibleMailbox that wraps ErrNoEmailAccounts so the three callers that pause a campaign on it are unaffected · Updated 2026-08-16 05:22:30 +00:00starred
0a0d68f7b0 · feat: create /data/blobs in the backend, consumer and worker images owned by the non-root user they run as, because Docker seeds a fresh named volume from the image and the path did not exist there, so it created the mount point root:root while every service runs as uid 1000 and the very first attempt to store an outbound body failed with 'mkdir /data/blobs/emails: permission denied' and the task went to the dead-letter queue, which is invisible until a real send is attempted since the stack is otherwise healthy, and documenting the one-time chown that existing installs still need because Docker only applies image ownership when it first creates the volume · Updated 2026-08-16 05:15:39 +00:00starred
15cd8e33aa · feat: fix the tracking dedupe insert that failed on every single open and click event, where the ON CONFLICT target named the expression COALESCE(url_hash, '') while the table's primary key is the three plain columns (task_id, event_type, url_hash) and no matching expression index exists, so Postgres rejected every insert with SQLSTATE 42P10 regardless of whether the row was actually a duplicate and tracking_events_processed stayed permanently empty, and NULLIF turned an open event's empty url_hash into NULL against a NOT NULL DEFAULT empty-string column which would have been refused even had the conflict target been right, leaving the consumer-level dedupe with nothing to check so a JetStream redelivery (MaxDeliver is 10, so not hypothetical) or an event slipping past the Rust service's in-memory hourly cache after a tracking restart could double-count an open or a click, and dropping the equally redundant COALESCE from the IsProcessed lookup so it can use the primary key · Updated 2026-08-16 05:13:42 +00:00starred
57ae54c139 · feat: thread Unibox dashboard replies into the conversation they answer, by carrying the composer's thread_id all the way from email_tasks to the provider (EmailMessage had no ThreadID field at all, so the column was read from the database and silently dropped in user_email_task, and Gmail only appends to an existing thread when threadId is set on the outbound message since a matching Subject and In-Reply-To do not do it), populating the models.SendEmail.Parent field that already existed with an avro tag and that the worker already read but nothing ever set, replacing the worker's gate that required InReplyTo to be non-empty before it would look at Parent (a dashboard reply never sets that header, so a perfectly valid ThreadID was discarded and the provider opened a new conversation) with a parentReference helper shared by the Gmail and Graph send paths that resolves the two genuinely independent handles separately, and backfilling the RFC In-Reply-To header server-side in UniboxReply from the newest Message-ID in the thread via a new org-scoped LatestMessageIDInThread query, because a provider thread id is meaningless outside the mailbox that issued it and the recipient's mail client can only thread on References and In-Reply-To · Updated 2026-08-16 05:11:51 +00:00starred
dc83d6de89 · feat: resume a reloaded Gmail mailbox from its saved history checkpoint instead of the never-written email_accounts.last_id column, which no code in the repository ever assigns so it is NULL forever and every worker restart, deploy, crash or docker compose up handed the mailbox a zero cursor that re-bootstrapped straight to Gmail's current historyId and silently skipped everything that arrived since the last successful sync, unrecoverably because the history API only walks forward from the id it is given, by threading the EmailHistoryIDRepository the consumer already writes to into emailService as the Google counterpart of the existing WireGraphDelta cursor plumbing for Outlook, reading it in buildAddWorkerEmail through a lastHistoryFor helper that prefers the live checkpoint, still falls back to last_id for rows carrying a value from before the checkpoint table existed, and returns zero only when genuinely nothing is known so a first-time mailbox bootstraps a fresh baseline, plus tests covering the precedence and every fallback including an unwired repository · Updated 2026-08-16 05:07:19 +00:00starred
f878e83832 · feat: make the Gmail history checkpoint persist and advance, by setting UserID and EmailID on the JobEventHistoryIDUpdate that NewHistoryID publishes (email_history_ids is keyed (user_id, email_id) with a foreign key to users, so the zero UUIDs it was sending made every checkpoint write fail on email_history_ids_user_id_fkey and no Gmail mailbox ever got a row, which is why replies, opens via label changes and every other inbound signal never reached Unibox), bootstrapping a mailbox with no baseline from Users.GetProfile instead of calling history.list with startHistoryId=0 which Gmail rejects with 'Requested entity was not found' so a freshly connected mailbox could never establish one, advancing the in-memory GoogleData.LastHistoryID after a successful walk since it was only ever written at construction and a stale cursor re-walks the window just processed while a zero one re-bootstraps past everything that arrived in between, and no longer discarding a MailError when a partial history was processed in the same tick, matching the field assignment the already-correct but uncalled ImapGoogleSync has had all along · Updated 2026-08-16 05:05:29 +00:00starred
7ff10fa789 · feat: send every Gmail message as raw RFC 5322 instead of the structured gmail.MessagePart payload, which Gmail's users.messages.send rejects outright with "'raw' RFC822 payload message string or uploading message via /upload/* URL required" because the structured Payload tree is the read representation returned by messages.get and is not accepted on send, so every non-attachment Gmail send failed with a 400 that was retried and then dead-lettered under a misleading SERVER_UNREACHABLE label while the attachment path already built raw correctly, building the narrowest correct MIME structure per message rather than routing everything through the multipart/mixed attachment builder (bare text/plain for warmup and text-only campaigns, multipart/alternative once there is an HTML body, multipart/mixed only when files are attached, because a needlessly nested tree is a structural difference cold outreach does not need), RFC 2047-encoding the Subject and building the From header through net/mail.Address now that header encoding is ours rather than the API's, so a non-ASCII display name is no longer emitted as bare 8-bit bytes and a name containing a comma no longer splits the header into two recipients, and adding tests that parse the built message back with net/mail and mime/multipart to assert the structure instead of matching strings · Updated 2026-08-16 05:03:20 +00:00starred
9d6e09072c · feat: wire OnTokenRefresh on the Gmail worker client so every send and sync stops panicking, since goog.Client was constructed with all four message and label callbacks but no token callback while goog.Init unconditionally wrapped the token source in stoken, whose Token() calls that callback on every single request from inside the oauth2 transport's RoundTrip, making the nil func value a guaranteed nil-pointer dereference on the first Gmail API call any mailbox made (the Outlook path immediately below it set the same field correctly, so no Microsoft mailbox was affected), additionally guarding both goog.Init and msgraph.Init so the stoken wrapper is only installed when there is somewhere to persist a refreshed token to, hardening stoken.Token itself against a nil callback because it runs inside RoundTrip where a panic takes down the caller's request rather than surfacing as an error, and adding a regression test that panics without the guard and passes with it · Updated 2026-08-16 05:00:24 +00:00starred
eda99d5998 · feat: accept the API's origin in the mailbox OAuth callback listener so connecting Gmail or Outlook completes on a split-domain deployment, where the bridge page is served by the backend (deliberately, so the registered redirect_uri survives front-end changes) and therefore arrives with event.origin equal to API_URL while the dashboard only ever compared it against APP_URL, silently discarding every callback and leaving the connect modal on 'Waiting for authorization' forever even though the provider exchange had already succeeded, normalising both configured bases through URL.origin so a trailing slash no longer breaks the comparison either, and separately deriving the bridge's postMessage target origin from APP_URL when APP_ORIGIN is unset instead of falling back to a wildcard that posts the authorization code to whatever origin the opener happens to have, since compose never set APP_ORIGIN despite the configuration table claiming it was derived, with the app_origin_wildcard health check and both docs pages updated to match the narrower condition that now triggers it · Updated 2026-08-16 04:58:35 +00:00starred
2f542e890b · feat: build the Gmail and Outlook mailbox-connect redirect_uri from API_PUBLIC_URL instead of API_HOST, which is the listener's bind address and stays 0.0.0.0:8080 in a container, so every self-hosted OAuth connect sent Google and Microsoft a redirect_uri that is not even an absolute URI and was rejected with invalid_request before the account picker appeared, adding an oauthPublicBaseURL helper next to the existing oidcRedirectURL that reads the same already-documented variable and falls back to a browsable http://localhost:PORT derived from the bind address (mapping the 0.0.0.0, :: and bare-port wildcard forms to localhost, and passing through a value that is already a URL) so a stock local install emits exactly the http://localhost:8080/addresses/google/callback the guide tells operators to register, plus a test pinning the resolution order and the root-registered callback path, and a deployment-guide note naming API_PUBLIC_URL as the variable that has to match the registered URI · Updated 2026-08-16 04:55:31 +00:00starred
ce930299ad · feat: seal Gmail and Outlook OAuth access and refresh tokens at rest in email_accounts_oauth instead of storing the provider's raw tokens, which the connect UI already promised were encrypted and which the read path could never open because it unconditionally hex-decodes, adding sealCredential/openCredential helpers that fail closed when CREDENTIALS_ENCRYPTION_KEY is unset, encrypting on both write paths (NewOauthAccount at connect time and RefreshBoxToken on every worker token refresh, which would otherwise revert a sealed row to plaintext on first refresh), migrating pre-existing plaintext rows lazily on first read because the key lives in the application and no SQL-only migration can reach it, restoring the missing return on the OAuth insert failure that let a failed token write commit an account row with no credentials, keeping token parameters out of Sentry error reports, and correcting the two docs tables that scoped the key to SMTP and IMAP only · Updated 2026-08-16 04:52:44 +00:00starred
2c148cef2a · feat: make self-hosted onboarding survivable by fixing invite_only, which could not onboard anyone (the accept route is JWT-only, so redeeming the invitation that would create your account required already having one, making the self-host default silently identical to fully closed), threading the invitation token through registration so an invited person lands in the inviting organization instead of a stray workspace, gating SSO just-in-time provisioning behind DISABLE_REGISTRATION (it bypassed the gate entirely, so an instance set to true was still open to anyone the IdP would assert) with SSO_AUTO_PROVISION as the opt-out, correcting the OIDC redirect URL that pointed at /api/v1 against a route at /v1 and 404'd every SSO login, scoping the first-launch exemption so it no longer overrides an explicit lockdown, preserving the remaining TTL when restoring a losing setup token so a public endpoint cannot hold the claim window open forever, replacing a generic 403 with typed registration_invite_only, registration_closed, invitation_invalid, setup_token_invalid and setup_already_complete codes that name the next step, logging why no claim link was issued on an already-claimed instance instead of staying silent, adding a warmblyctl operator CLI (status with health checks and a non-zero exit, reissuable setup-link, user create/list/reset-password/grant-admin/revoke-admin/disable-2fa, hash-password) so a locked-out operator no longer needs hand-written psql, adding read-only instance configuration over 104 environment variables with structural secret redaction and fingerprints, 35 health checks, a database-backed settings tier for the three keys no environment variable owns, hiding the signup form when the config already says invite_only rather than failing the whole form with a toast, and documenting first run, accounts and access, configuration, instance health and troubleshooting alongside the root .env.example the README told operators to write but never shipped · Updated 2026-08-16 03:54:53 +00:00starred
1387541bf0 · feat: make self-hosted auth work without a mail relay by rewriting the platform SMTP transport with real AUTH and TLS (it did neither, so SMTP_USERNAME/SMTP_PASSWORD were dead and every documented relay was unreachable), adding MAIL_TRANSPORT=smtp|log|ses with a log transport that prints codes so a fresh install can sign in with no relay, demoting the emailed login code to AUTH_LOGIN_CODE=always|new_device|off (off on self-host, per NIST SP 800-63B and OWASP ASVS), claiming the first owner through a single-use setup link or WARMBLY_BOOTSTRAP_* instead of register-then-psql, deriving every emailed URL from APP_URL rather than a hardcoded app.warmbly.com that leaked live reset tokens to the vendor, fixing the confirm hooks that read path params against paramless routes and broke login, register and reset confirmation in the dashboard everywhere, adding generic OIDC with PKCE, one-time state, verified nonce and (issuer,subject) identity binding, enforcing 2FA on the social paths that skipped it, adding a per-IP limiter and trusted-proxy handling to the unthrottled auth group, refusing boot on the published default secrets, and dropping mailpit from the default stack · Updated 2026-08-14 08:37:14 +00:00starred
a75590d628 · feat: stop every scheduler from ever handing back a slot in the past (symmetric jitter and the sub-minute humanizer could both move a near-term send backwards, where it fires with none of its spacing or is cancelled as overdue), return 404 instead of 500 for a mailbox the caller does not own, and add live Postgres integration tests that drive the real campaign scheduler and guardrail sweep end to end · Updated 2026-08-13 14:45:48 +00:00starred
446ecad33c · feat: tell the user what to do when they try to connect a Gmail or Microsoft mailbox on a deployment with no OAuth client, replacing the generic 500 with a 503 carrying a stable mailbox_provider_not_configured code, the exact BOX_* variables to set, and a link to the environment setup guide, rendered as an inline panel in the add-mailbox modal instead of a truncated toast · Updated 2026-08-13 07:36:13 +00:00starred
55b2c7f264 · feat: revise the Make app README with public-repo credential guidance (client secret lives in Make Common Data, never committed) and a CI validation section · Updated 2026-06-30 04:17:44 +00:00starred
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?