feat: address the CodeRabbit review on the mailbox allowance work by reserving the mailbox slot inside the account insert transaction under a per-organization lock so concurrent connects cannot both take the last one, resolving the storage limit under the quota lock so a plan change cannot be raced past, deleting refused attachment objects on a context that outlives the request, settling already-connected rows before the bulk batch spends allowance, leaving password columns out of the failed-rows CSV, stopping click propagation from the portalled allowance dialog, and counting bulk progress once

This commit is contained in:
Matthew Meszaros
2026-09-04 21:11:25 -07:00
parent 3596a9770f
commit bf46839fb7
14 changed files with 193 additions and 76 deletions
+1 -1
View File
@@ -88,7 +88,7 @@ The dialog offers a template with these headers, and accepts the common spelling
The run then streams the rows to the server in small batches. Every credential is verified against its own server before it is saved, the same as a single connect, so a large file takes a few seconds per mailbox; the dialog shows live progress and the mailbox list behind it fills in as they land, for everyone in the workspace. You can stop after the batch in flight. Closing the tab loses nothing that was already connected.
**When it is done** you see how many connected, how many were already here, and how many did not connect, with a reason per row. **Download failed rows** gives you those rows exactly as you uploaded them (passwords included) plus an `error` column, so you can fix and upload that file. Re-uploading is always safe: a mailbox that is already connected is skipped, never doubled.
**When it is done** you see how many connected, how many were already here, and how many did not connect, with a reason per row. **Retry failed** runs those rows again without leaving the dialog, passwords still in memory. **Download failed rows** gives you those rows as you uploaded them plus an `error` column, with the password columns left out so no credential lands in a Downloads folder; add them back before uploading the fixed file. Re-uploading is always safe: a mailbox that is already connected is skipped, never doubled.
The same endpoint is available to the API as `POST /emails/onboarding/smtp-imap/bulk`, up to `50` rows per call, answered per row.
+27 -4
View File
@@ -7,6 +7,7 @@ package handler
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
@@ -14,6 +15,7 @@ import (
"strings"
"time"
"github.com/getsentry/sentry-go"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
@@ -71,6 +73,17 @@ func (h *Handler) attachmentCampaign(c *gin.Context) (campaignID, orgID uuid.UUI
return campaignID, *org, nil
}
// deleteObjectDetached removes an object whose row was never written, on a
// bounded context that is not cancelled with the request: a client that gives
// up mid-upload must not leave bytes in storage that no quota counts.
func (h *Handler) deleteObjectDetached(ctx context.Context, key string) {
cleanup, cancel := context.WithTimeout(context.WithoutCancel(ctx), 15*time.Second)
defer cancel()
if err := h.Storage.Delete(cleanup, key); err != nil {
sentry.CaptureException(fmt.Errorf("attachment %s: cleanup after refused reservation: %w", key, err))
}
}
// UploadCampaignAttachment — POST /campaigns/:id/attachments (multipart "file")
func (h *Handler) UploadCampaignAttachment(c *gin.Context) {
campaignID, orgID, xerr := h.attachmentCampaign(c)
@@ -181,15 +194,25 @@ func (h *Handler) UploadCampaignAttachment(c *gin.Context) {
}
// The row is the reservation: it is written only if the total still fits
// once this upload is counted, so concurrent uploads cannot interleave
// past the limit. A refused file is removed from storage again.
created, used, err := h.AttachmentRepo.CreateWithinQuota(c.Request.Context(), att, orgID, limit)
// past the limit. The limit is re-read under the lock so a plan change
// that lands between the pre-check and the insert is honored. A refused
// file is removed from storage again, on a context that outlives the
// request so a cancelled upload cannot strand the object.
limitFn := func(ctx context.Context) (int64, error) {
l, xerr := h.FeatureGateService.GetStorageLimitBytes(ctx, orgID)
if xerr != nil {
return 0, xerr
}
return l, nil
}
created, used, limit, err := h.AttachmentRepo.CreateWithinQuota(c.Request.Context(), att, orgID, limitFn)
if err != nil {
_ = h.Storage.Delete(c.Request.Context(), key) // best-effort cleanup
h.deleteObjectDetached(c.Request.Context(), key)
errx.JSON(c, errx.InternalError())
return
}
if !created {
_ = h.Storage.Delete(c.Request.Context(), key)
h.deleteObjectDetached(c.Request.Context(), key)
errx.JSON(c, errx.StorageLimitReached(used, limit, fh.Size))
return
}
+22 -12
View File
@@ -229,13 +229,13 @@ func (s *campaignService) Duplicate(ctx context.Context, orgID, userID uuid.UUID
}
campaign, err := s.campaignRepository.Duplicate(ctx, repository.DuplicateCampaignInput{
SourceID: cID,
NewID: newID,
UserID: userID,
Name: name,
Attachments: copied,
OrganizationID: orgID,
StorageLimitBytes: storageLimit,
SourceID: cID,
NewID: newID,
UserID: userID,
Name: name,
Attachments: copied,
OrganizationID: orgID,
StorageLimit: storageLimit,
})
if err != nil {
cleanup()
@@ -250,7 +250,7 @@ func (s *campaignService) Duplicate(ctx context.Context, orgID, userID uuid.UUID
used, _ := s.attachmentRepo.SumStorageUsedByOrg(ctx, orgID)
var limit int64
if storageLimit != nil {
limit = *storageLimit
limit, _ = storageLimit(ctx)
}
return nil, errx.StorageLimitReached(used, limit, adding)
}
@@ -290,7 +290,7 @@ func (s *campaignService) Duplicate(ctx context.Context, orgID, userID uuid.UUID
// and the insert re-checks under the quota lock. An attachment whose bytes
// cannot be read is reported and skipped rather than failing the whole
// duplicate.
func (s *campaignService) copyAttachments(ctx context.Context, orgID, src, dst uuid.UUID) ([]models.CampaignAttachment, *int64, func(), *errx.Error) {
func (s *campaignService) copyAttachments(ctx context.Context, orgID, src, dst uuid.UUID) ([]models.CampaignAttachment, repository.StorageLimitFunc, func(), *errx.Error) {
noop := func() {}
if s.attachmentRepo == nil || s.storage == nil {
return nil, nil, noop, nil
@@ -303,13 +303,19 @@ func (s *campaignService) copyAttachments(ctx context.Context, orgID, src, dst u
return nil, nil, noop, nil
}
var limit *int64
var limit repository.StorageLimitFunc
if s.featureGate != nil {
l, xerr := s.featureGate.GetStorageLimitBytes(ctx, orgID)
if xerr != nil {
return nil, nil, noop, xerr
}
limit = &l
limit = func(ctx context.Context) (int64, error) {
v, xerr := s.featureGate.GetStorageLimitBytes(ctx, orgID)
if xerr != nil {
return 0, xerr
}
return v, nil
}
used, err := s.attachmentRepo.SumStorageUsedByOrg(ctx, orgID)
if err != nil {
return nil, nil, noop, errx.InternalError()
@@ -341,8 +347,12 @@ func (s *campaignService) copyAttachments(ctx context.Context, orgID, src, dst u
copied = append(copied, att)
}
return copied, limit, func() {
// The undo must outlive a cancelled request, or the copies are left in
// storage with no row counting them.
cleanup, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
defer cancel()
for _, att := range copied {
if err := s.storage.Delete(ctx, att.S3Key); err != nil {
if err := s.storage.Delete(cleanup, att.S3Key); err != nil {
sentry.CaptureException(fmt.Errorf("campaign %s duplicate undo: object %s: %w", src, att.S3Key, err))
}
}
+3 -1
View File
@@ -27,7 +27,8 @@ func (s *emailService) OAuthConnectWithCode(ctx context.Context, userID string,
if code = strings.TrimSpace(code); code == "" {
return nil, errx.ErrEmailOnboardCode
}
if xerr := s.guardInboxLimit(ctx, orgID); xerr != nil {
allowance, xerr := s.guardInboxLimit(ctx, orgID)
if xerr != nil {
return nil, xerr
}
cfg, xerr := s.oauthConfigFor(provider)
@@ -53,6 +54,7 @@ func (s *emailService) OAuthConnectWithCode(ctx context.Context, userID string,
}
acc, xerr := s.emailRepository.NewOauthAccount(ctx, userID, models.NewOauthAccount{
OrganizationID: orgID,
Allowance: allowance,
Provider: provider,
Name: name,
Email: owner.Email,
+13 -2
View File
@@ -59,8 +59,9 @@ func (s *emailService) OnboardSMTPIMAPBulk(ctx context.Context, userID string, o
}
}
// Duplicates inside the file are refused before they compete for the
// allowance; the first occurrence is the one that gets connected.
// Duplicates inside the file and mailboxes that are already connected are
// settled before anything competes for the allowance, so a re-uploaded
// file never spends a slot on a row that would create nothing.
seen := make(map[string]bool, len(rows))
eligible := make([]int, 0, len(rows))
for i := range rows {
@@ -70,6 +71,16 @@ func (s *emailService) OnboardSMTPIMAPBulk(ctx context.Context, userID string, o
continue
}
seen[key] = true
if exists, xerr := s.emailRepository.ExistsForUser(ctx, userID, strings.TrimSpace(rows[i].Email)); xerr != nil {
fail(i, xerr)
continue
} else if exists {
res.Data[i] = models.MailboxBulkRow{
Row: i, Email: rows[i].Email, Status: models.MailboxBulkSkipped,
Code: "already_connected", Message: errx.ErrEmailOnboardAlreadyExists.Message,
}
continue
}
if len(eligible) >= remaining {
used, limit := 0, 0
paid := true
+24 -16
View File
@@ -28,7 +28,7 @@ func (s *emailService) OAuthStart(ctx context.Context, userID string, orgID *uui
// Refuse early so we don't waste an OAuth round-trip on a request
// that the inbox-limit guard would reject after callback.
if xerr := s.guardInboxLimit(ctx, orgID); xerr != nil {
if _, xerr := s.guardInboxLimit(ctx, orgID); xerr != nil {
return nil, xerr
}
@@ -57,38 +57,40 @@ func (s *emailService) OAuthStart(ctx context.Context, userID string, orgID *uui
// guardInboxLimit refuses a connect that would take the workspace past its
// mailbox allowance (fair use for paid plans, FreeWorkspaceMailboxLimit for
// free ones, unlimited without billing). The allowance is counted per org, so
// no org means it cannot be applied and the connect is refused. Without an
// allowance source wired, the feature gate's free-or-paid split stands in.
func (s *emailService) guardInboxLimit(ctx context.Context, orgID *uuid.UUID) *errx.Error {
// free ones, unlimited without billing) and returns the resolved allowance so
// the insert can enforce it again under the organization's lock. The
// allowance is counted per org, so no org means it cannot be applied and the
// connect is refused. Without an allowance source wired, the feature gate's
// free-or-paid split stands in and the insert is not re-checked.
func (s *emailService) guardInboxLimit(ctx context.Context, orgID *uuid.UUID) (*models.MailboxAllowance, *errx.Error) {
if orgID == nil {
return errx.ErrNoOrganization
return nil, errx.ErrNoOrganization
}
if s.allowance != nil {
a, xerr := s.allowance.MailboxAllowance(ctx, *orgID)
if xerr != nil {
return xerr
return nil, xerr
}
if a.CanAdd(1) {
return nil
return a, nil
}
return errx.MailboxAllowanceReached(a.Used, *a.Allowance, a.Paid)
return nil, errx.MailboxAllowanceReached(a.Used, *a.Allowance, a.Paid)
}
if s.featureGate == nil {
return nil
return nil, nil
}
count, xerr := s.emailRepository.CountForOrganization(ctx, *orgID)
if xerr != nil {
return xerr
return nil, xerr
}
allowed, xerr := s.featureGate.CanAddInbox(ctx, *orgID, count)
if xerr != nil {
return xerr
return nil, xerr
}
if allowed {
return nil
return nil, nil
}
return errx.MailboxAllowanceReached(count, models.FreeWorkspaceMailboxLimit, false)
return nil, errx.MailboxAllowanceReached(count, models.FreeWorkspaceMailboxLimit, false)
}
// OAuthFinish validates the state, exchanges the code for tokens, fetches the
@@ -111,10 +113,13 @@ func (s *emailService) OAuthFinish(ctx context.Context, userID, code, state stri
}
// A reauth adds no mailbox, so an org over its inbox cap can still fix one.
var allowance *models.MailboxAllowance
if sess.EmailAccountID == nil {
if xerr := s.guardInboxLimit(ctx, sess.OrganizationID); xerr != nil {
a, xerr := s.guardInboxLimit(ctx, sess.OrganizationID)
if xerr != nil {
return nil, false, xerr
}
allowance = a
}
provider := models.InboxProvider(sess.Provider)
@@ -151,6 +156,7 @@ func (s *emailService) OAuthFinish(ctx context.Context, userID, code, state stri
acc, xerr := s.emailRepository.NewOauthAccount(ctx, userID, models.NewOauthAccount{
OrganizationID: sess.OrganizationID,
Allowance: allowance,
Provider: provider,
Name: name,
Email: owner.Email,
@@ -176,7 +182,8 @@ func (s *emailService) OnboardSMTPIMAP(ctx context.Context, userID string, orgID
return nil, xerr
}
if xerr := s.guardInboxLimit(ctx, orgID); xerr != nil {
allowance, xerr := s.guardInboxLimit(ctx, orgID)
if xerr != nil {
return nil, xerr
}
@@ -210,6 +217,7 @@ func (s *emailService) OnboardSMTPIMAP(ctx context.Context, userID string, orgID
}
data.OrganizationID = orgID
data.Allowance = allowance
acc, xerr := s.emailRepository.NewSMTPIMAPAccount(ctx, userID, *data)
if xerr != nil {
+16 -10
View File
@@ -220,20 +220,26 @@ type Oauth2SmtpImap struct {
type NewOauthAccount struct {
OrganizationID *uuid.UUID
Provider InboxProvider
Name string
Email string
AccessToken string
RefreshToken string
ExpiresAt time.Time
// Allowance, when set, is enforced again inside the insert transaction
// under the organization's mailbox lock, so concurrent connects cannot
// both take the last slot.
Allowance *MailboxAllowance
Provider InboxProvider
Name string
Email string
AccessToken string
RefreshToken string
ExpiresAt time.Time
}
type NewSMTPIMAPAccount struct {
OrganizationID *uuid.UUID
Name string
Email string
SMTP *Service
IMAP *Service
// Allowance: see NewOauthAccount.
Allowance *MailboxAllowance
Name string
Email string
SMTP *Service
IMAP *Service
}
// EmailOnboardingState is stored in Redis for the lifetime of an OAuth round trip.
+23 -13
View File
@@ -30,13 +30,19 @@ type AttachmentRepository interface {
// (joined through campaigns) — the basis for the per-plan storage quota.
SumStorageUsedByOrg(ctx context.Context, orgID uuid.UUID) (int64, error)
// CreateWithinQuota inserts the row only if the organization's total stays
// within limitBytes. The check and the insert share one transaction under
// the org's attachment lock (LockStorageQuota), so two uploads in flight
// cannot both read the same total and both pass (issue #326). Returns
// created=false and the total it saw when the file does not fit.
CreateWithinQuota(ctx context.Context, att *models.CampaignAttachment, orgID uuid.UUID, limitBytes int64) (created bool, used int64, err error)
// within the limit. The limit is resolved by limitFn AFTER the org's
// attachment lock (LockStorageQuota) is held, and the check and the insert
// share that transaction, so two uploads in flight cannot both read the
// same total and both pass, and a plan change cannot be raced past
// (issue #326). Returns created=false, the total it saw and the limit it
// applied when the file does not fit.
CreateWithinQuota(ctx context.Context, att *models.CampaignAttachment, orgID uuid.UUID, limitFn StorageLimitFunc) (created bool, used, limit int64, err error)
}
// StorageLimitFunc resolves an organization's storage quota in bytes. It is
// called under the quota lock so the value cannot go stale before the insert.
type StorageLimitFunc func(ctx context.Context) (int64, error)
// LockStorageQuota serialises quota checks for one organization inside the
// calling transaction. Every writer of campaign_attachments that checks the
// quota takes it first, so the sum it reads cannot go stale before its insert.
@@ -83,22 +89,26 @@ func (r *attachmentRepository) Create(ctx context.Context, att *models.CampaignA
), att)
}
func (r *attachmentRepository) CreateWithinQuota(ctx context.Context, att *models.CampaignAttachment, orgID uuid.UUID, limitBytes int64) (bool, int64, error) {
func (r *attachmentRepository) CreateWithinQuota(ctx context.Context, att *models.CampaignAttachment, orgID uuid.UUID, limitFn StorageLimitFunc) (bool, int64, int64, error) {
tx, err := r.DB.Begin(ctx)
if err != nil {
return false, 0, err
return false, 0, 0, err
}
defer func() { _ = tx.Rollback(ctx) }()
if err := LockStorageQuota(ctx, tx, orgID); err != nil {
return false, 0, err
return false, 0, 0, err
}
limit, err := limitFn(ctx)
if err != nil {
return false, 0, 0, err
}
used, err := storageUsedTx(ctx, tx, orgID)
if err != nil {
return false, 0, err
return false, 0, limit, err
}
if used+att.Size > limitBytes {
return false, used, nil
if used+att.Size > limit {
return false, used, limit, nil
}
if err := scanAttachment(tx.QueryRow(ctx, `
INSERT INTO campaign_attachments (campaign_id, sequence_id, user_id, filename, size, mime_type, s3_key)
@@ -106,9 +116,9 @@ func (r *attachmentRepository) CreateWithinQuota(ctx context.Context, att *model
RETURNING `+attachmentCols,
att.CampaignID, att.SequenceID, att.UserID, att.Filename, att.Size, att.MimeType, att.S3Key,
), att); err != nil {
return false, used, err
return false, used, limit, err
}
return true, used + att.Size, tx.Commit(ctx)
return true, used + att.Size, limit, tx.Commit(ctx)
}
func (r *attachmentRepository) GetByID(ctx context.Context, id uuid.UUID) (*models.CampaignAttachment, error) {
+13 -8
View File
@@ -22,11 +22,12 @@ type DuplicateCampaignInput struct {
UserID uuid.UUID
Name string
Attachments []models.CampaignAttachment
// OrganizationID and StorageLimitBytes make the copied attachments count
// against the quota inside the same transaction that inserts them. A nil
// limit skips the check.
OrganizationID uuid.UUID
StorageLimitBytes *int64
// OrganizationID and StorageLimit make the copied attachments count
// against the quota inside the same transaction that inserts them; the
// limit is resolved under the quota lock. A nil StorageLimit skips the
// check.
OrganizationID uuid.UUID
StorageLimit StorageLimitFunc
}
// ErrStorageQuotaExceeded is returned by Duplicate when the copied attachments
@@ -172,11 +173,15 @@ func (r *campaignRepository) Duplicate(ctx context.Context, in DuplicateCampaign
return nil, err
}
if len(in.Attachments) > 0 && in.StorageLimitBytes != nil {
if len(in.Attachments) > 0 && in.StorageLimit != nil {
if err := LockStorageQuota(ctx, tx, in.OrganizationID); err != nil {
db.CaptureError(err, "", nil, "exec")
return nil, err
}
limit, err := in.StorageLimit(ctx)
if err != nil {
return nil, err
}
used, err := storageUsedTx(ctx, tx, in.OrganizationID)
if err != nil {
db.CaptureError(err, "", nil, "queryrow")
@@ -186,8 +191,8 @@ func (r *campaignRepository) Duplicate(ctx context.Context, in DuplicateCampaign
for _, att := range in.Attachments {
adding += att.Size
}
if used+adding > *in.StorageLimitBytes {
return nil, fmt.Errorf("%w: %d of %d bytes used, %d to add", ErrStorageQuotaExceeded, used, *in.StorageLimitBytes, adding)
if used+adding > limit {
return nil, fmt.Errorf("%w: %d of %d bytes used, %d to add", ErrStorageQuotaExceeded, used, limit, adding)
}
}
+31
View File
@@ -305,6 +305,29 @@ func (r *emailRepository) CountForOrganization(ctx context.Context, orgID uuid.U
return count, nil
}
// reserveMailboxSlotTx is the final allowance check, run inside the insert
// transaction under a per-organization lock so two connects that both saw one
// slot free cannot both take it. The service's earlier read is for feedback;
// this is what enforces.
func reserveMailboxSlotTx(ctx context.Context, tx pgx.Tx, orgID *uuid.UUID, a *models.MailboxAllowance) *errx.Error {
if a == nil || a.Allowance == nil || orgID == nil {
return nil
}
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtext('email_accounts'), hashtext($1::text))`, orgID.String()); err != nil {
db.CaptureError(err, "", nil, "exec")
return errx.InternalError()
}
var count int
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM email_accounts WHERE organization_id = $1`, *orgID).Scan(&count); err != nil {
db.CaptureError(err, "", nil, "queryrow")
return errx.InternalError()
}
if count >= *a.Allowance {
return errx.MailboxAllowanceReached(count, *a.Allowance, a.Paid)
}
return nil
}
func (r *emailRepository) NewOauthAccount(ctx context.Context, userID string, data models.NewOauthAccount) (*models.Email, *errx.Error) {
if data.Provider == models.InboxProviderSMTPIMAP {
sentry.CaptureException(errors.New("invalid inbox provider"))
@@ -331,6 +354,10 @@ func (r *emailRepository) NewOauthAccount(ctx context.Context, userID string, da
}
defer tx.Rollback(ctx)
if xerr := reserveMailboxSlotTx(ctx, tx, data.OrganizationID, data.Allowance); xerr != nil {
return nil, xerr
}
sigplain := utils.GetSignaturePlain(data.Name)
sightml := utils.GetSignatureHTML(data.Name)
@@ -462,6 +489,10 @@ func (r *emailRepository) NewSMTPIMAPAccount(ctx context.Context, userID string,
}
defer tx.Rollback(ctx)
if xerr := reserveMailboxSlotTx(ctx, tx, data.OrganizationID, data.Allowance); xerr != nil {
return nil, xerr
}
sigplain := utils.GetSignaturePlain(data.Name)
sightml := utils.GetSignatureHTML(data.Name)
@@ -407,7 +407,7 @@ function mailboxHint(a: OrganizationLimits["mailboxes"] | undefined): string {
case "override":
return "Raised for this workspace by an approved request";
case "free":
return "Free workspace allowance; every paid plan holds unlimited mailboxes";
return "Free workspace allowance; a paid plan holds one mailbox for every send a day it includes";
case "plan":
return "Set by your plan";
default:
@@ -286,7 +286,9 @@ export default function BulkConnectPanel({
const skipped = rows.filter((r) => r.status === "skipped").length;
const failed = rows.filter((r) => r.status === "failed").length;
const pending = rows.filter((r) => r.status === "pending").length;
const total = ready + connected + skipped + failed + pending;
// `ready` already includes pending rows, so count the untouched ones alone.
const notStarted = rows.filter((r) => r.status === "ready").length;
const total = notStarted + connected + skipped + failed + pending;
if (step === "run") {
const done = connected + skipped + failed;
@@ -406,7 +408,7 @@ export default function BulkConnectPanel({
<button
type="button"
onClick={downloadFailed}
title="The rows as you uploaded them, including passwords, plus an error column"
title="The rows as you uploaded them, minus passwords, plus an error column"
className="ml-auto h-6 px-2 rounded text-[11px] text-slate-700 hover:text-slate-900 hover:bg-slate-100 inline-flex items-center gap-1 transition-colors"
>
<DownloadIcon className="w-3 h-3" />
@@ -444,7 +446,7 @@ export default function BulkConnectPanel({
<div className="px-4 py-2.5 border-t border-slate-200 bg-slate-50/60 flex items-center gap-2 min-w-0 sticky bottom-0">
<div className="text-[11px] text-slate-500 min-w-0 flex-1 truncate">
{failedRows.length > 0
? "Fix the failed rows in the download and upload that file; connected ones are skipped."
? "Retry keeps the passwords in memory. The download leaves them out; add them back before re-uploading."
: "Warmup and sync start on every new mailbox right away."}
</div>
{(canRetry || notSent > 0) && (
@@ -8,8 +8,10 @@
// pointed at a plan instead, because that is its only path to more.
//
// Layered above the connect modal: it marks itself data-floating so the modal
// underneath ignores Escape while it is up, and stops mousedown so the
// modal's backdrop handler never sees a click inside it.
// underneath ignores Escape while it is up, and stops both mousedown and
// click. The portal keeps it in the modal's React tree, so without that a
// click anywhere in here would reach the modal's backdrop handler and close
// the whole connect flow.
import React from "react";
import { createPortal } from "react-dom";
@@ -121,6 +123,7 @@ export default function MailboxAllowanceDialog({
e.stopPropagation();
onClose();
}}
onClick={(e) => e.stopPropagation()}
className="fixed inset-0 z-[140] flex items-center justify-center bg-slate-900/40 backdrop-blur-[2px] px-4"
>
<motion.div
@@ -206,10 +206,16 @@ export function downloadTemplate() {
downloadBlob(new Blob(["" + csv], { type: "text/csv;charset=utf-8" }), "warmbly-mailboxes-template.csv");
}
/** The rows that did not connect, with the user's own columns plus the reason. */
/** Columns never written to a download: a CSV in a Downloads folder is
* synced, backed up and shared far more than the user intends. */
const SECRET_COLUMNS = new Set(["password", "smtp_password", "imap_password"]);
/** The rows that did not connect, with the user's own columns (minus
* passwords) plus the reason. */
export function failedRowsCSV(rows: BulkRow[], columns: string[]): string {
const header = [...columns, "error"];
const body = rows.map((r) => [...columns.map((c) => r.raw[c] ?? ""), r.problem ?? r.message ?? r.code ?? ""]);
const keep = columns.filter((c) => !SECRET_COLUMNS.has(c));
const header = [...keep, "error"];
const body = rows.map((r) => [...keep.map((c) => r.raw[c] ?? ""), r.problem ?? r.message ?? r.code ?? ""]);
return Papa.unparse([header, ...body]);
}