diff --git a/cmd/backend/main.go b/cmd/backend/main.go
index c1670f7d..00a042ea 100644
--- a/cmd/backend/main.go
+++ b/cmd/backend/main.go
@@ -316,6 +316,7 @@ func main() {
var twofaService twofa.Service
var contactRepoForHandler repository.ContactRepository
var attachmentRepoForHandler repository.AttachmentRepository
+ var emailImageRepoForHandler repository.EmailImageRepository
var leadSyncServiceForHandler leadsync.Service
ctx, cancel := context.WithCancel(context.Background())
@@ -600,6 +601,7 @@ func main() {
sequenceRepostory := repository.NewSequenceRepostory(primaryDB)
contactRepostory := repository.NewContactRepostory(primaryDB)
attachmentRepoForHandler = repository.NewAttachmentRepository(primaryDB)
+ emailImageRepoForHandler = repository.NewEmailImageRepository(primaryDB)
uniboxRepository := repository.NewUniboxRepository(primaryDB)
encryptedKeys, err = encryptedkeys.FromEnv(
encryptedkeys.Deps{DB: primaryDB},
@@ -2094,6 +2096,7 @@ func main() {
UserRepo: userRepoForHandler,
OrgRepo: organizationRepoForHandler,
AttachmentRepo: attachmentRepoForHandler,
+ EmailImageRepo: emailImageRepoForHandler,
StorageBackendRepo: storageBackendRepo,
CloudCredentialRepo: cloudCredentialRepo,
ProvisioningTemplateRepo: provisioningTemplateRepo,
diff --git a/docs/content/docs/api/endpoints.mdx b/docs/content/docs/api/endpoints.mdx
index f7108dab..28949516 100644
--- a/docs/content/docs/api/endpoints.mdx
+++ b/docs/content/docs/api/endpoints.mdx
@@ -73,9 +73,14 @@ All paths below are relative to the versioned base URL `https://api.warmbly.com/
| POST | `/generation/write` | `WRITE_CAMPAIGNS` |
| POST | `/generation/edit` | `WRITE_CAMPAIGNS` |
| POST | `/generation/ai-variable` | `WRITE_CAMPAIGNS` |
+| GET | `/email-images` | `READ_CAMPAIGNS` |
+| POST | `/email-images` | `WRITE_CAMPAIGNS` |
+| DELETE | `/email-images/:id` | `WRITE_CAMPAIGNS` |
`GET /campaigns/:id/forms` reports the forms this campaign links to and what its recipients did with them: personalized links handed out, who opened one, who started filling it in and who submitted. See the [forms guide](/guides/forms/).
+`/email-images` is the workspace's image library for email bodies. `POST` takes a multipart `file` field (PNG, JPG, GIF or WebP, up to 5 MB) and returns the row with the public `url` you put in an ``; those bytes count against the same storage quota as campaign attachments. `GET` is keyset-paginated newest first (`?limit=` 1 to 100, `?cursor=` the opaque `pagination.next_cursor`). `DELETE` removes the object before the row and refuses with a `503` if storage will not take the delete, so a failed call changes nothing and can be retried; once it succeeds, an image in mail already sent stops loading.
+
### Contacts
| Method | Path | API Permission |
diff --git a/docs/content/docs/api/error-codes.mdx b/docs/content/docs/api/error-codes.mdx
index 33da2d6b..6b5e533c 100644
--- a/docs/content/docs/api/error-codes.mdx
+++ b/docs/content/docs/api/error-codes.mdx
@@ -339,12 +339,12 @@ A `403` whose `code` is `mailbox_allowance_reached` comes from every path that c
#### `storage_limit_reached`
-A `400` whose `code` is `storage_limit_reached` comes from `POST /campaigns/:id/attachments` and from a campaign duplicate that would copy attachments. The workspace's attachment storage, summed across every campaign, would pass its quota. The check and the write happen together under a per-workspace lock, so two uploads racing for the last of the quota cannot both get in. Nothing was stored.
+A `400` whose `code` is `storage_limit_reached` comes from `POST /campaigns/:id/attachments`, from `POST /email-images`, and from a campaign duplicate that would copy attachments. The workspace's stored bytes, its attachments across every campaign plus its email image library, would pass the quota. The check and the write happen together under one per-workspace lock shared by both, so two uploads racing for the last of the quota cannot both get in. Nothing was stored.
```json
{
"error": "Bad Request",
- "message": "Storage limit reached: 51190 MB of 51200 MB used, 12 MB to add. Remove attachments or upgrade your plan.",
+ "message": "Storage limit reached: 51190 MB of 51200 MB used, 12 MB to add. Remove attachments or images, or upgrade your plan.",
"code": "storage_limit_reached",
"request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}
@@ -352,7 +352,7 @@ A `400` whose `code` is `storage_limit_reached` comes from `POST /campaigns/:id/
**How to fix:**
- `GET /organization/current/limits` reports `storage.used_bytes` and `storage.limit_bytes`, and `storage.over_quota` when a plan change left the workspace above the quota. Existing attachments keep sending either way
-- Delete attachments you no longer need (`DELETE /campaigns/:id/attachments/:attachmentId`), or move to a paid plan for the larger quota
+- Delete attachments you no longer need (`DELETE /campaigns/:id/attachments/:attachmentId`) or images (`DELETE /email-images/:id`), or move to a paid plan for the larger quota
#### `mailbox_worker_unreachable`
diff --git a/docs/content/docs/development/configuration.mdx b/docs/content/docs/development/configuration.mdx
index 911055c2..9c08d6a1 100644
--- a/docs/content/docs/development/configuration.mdx
+++ b/docs/content/docs/development/configuration.mdx
@@ -250,9 +250,9 @@ An empty `CREDENTIALS_ENCRYPTION_KEY` does not fail at boot. It disables sealing
| Variable | What it does | Default | Restart needed |
|---|---|---|---|
| `BLOB_PROVIDER` | `filesystem` or `s3` | `filesystem` under compose, `s3` for a bare binary | yes |
-| `BLOB_FS_ROOT` | Directory for stored bodies, attachments and avatars. The backend, the consumer and every worker on the host must share it | `/data/blobs` | yes |
+| `BLOB_FS_ROOT` | Directory for stored bodies, attachments, avatars and email body images. The backend, the consumer and every worker on the host must share it | `/data/blobs` | yes |
| `BLOB_BUCKET` | Bucket name when `BLOB_PROVIDER=s3` | unset | yes |
-| `BLOB_PUBLIC_BASE_URL` | Public base for the backend's `/public` route | derived | yes |
+| `BLOB_PUBLIC_BASE_URL` | Public base for the backend's `/public` route. Images placed in an email body are served from here, so it has to be an address a recipient's mail client can reach | derived | yes |
| `AWS_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` | Credentials for S3 or SES | unset | yes |
| `AWS_ENDPOINT_URL_S3` | Non-AWS S3 endpoint (MinIO, R2, B2) | unset | yes |
| `AWS_CONFIG_ENABLED` | `true` reads secrets from AWS SSM or Secrets Manager | `false` | yes |
diff --git a/docs/content/docs/development/data-control.mdx b/docs/content/docs/development/data-control.mdx
index 89016f66..41fccabb 100644
--- a/docs/content/docs/development/data-control.mdx
+++ b/docs/content/docs/development/data-control.mdx
@@ -20,7 +20,7 @@ Every store's location is one variable in `.env`. Compose reads a source startin
| Variable | Holds | Default |
|---|---|---|
| `WARMBLY_PG_DATA` | Postgres: organizations, users, mailboxes (credentials sealed), contacts, campaigns, the audit trail | `/postgres` |
-| `WARMBLY_BLOBS` | Message bodies, attachments, avatars and logos | `/blobs` |
+| `WARMBLY_BLOBS` | Message bodies, attachments, avatars, logos and email body images | `/blobs` |
| `WARMBLY_NATS_DATA` | The event bus's JetStream state | `/nats` |
| `WARMBLY_REDIS_DATA` | Cache and rate-limit counters. Disposable | `/redis` |
| `WARMBLY_WORKER_STATE` | A worker's own id and sync cursors. Disposable | `/worker` |
diff --git a/docs/content/docs/guides/sequences.mdx b/docs/content/docs/guides/sequences.mdx
index e6bcd306..8cef5587 100644
--- a/docs/content/docs/guides/sequences.mdx
+++ b/docs/content/docs/guides/sequences.mdx
@@ -20,7 +20,35 @@ Each card has two source dots: the **bottom dot** is a plain "go there next" con
A step has an internal **name** (never seen by recipients), a **subject** with a variable menu for merge fields like `{{.FirstName}}`, and a rich-text **body**.
-The Preview tab renders through the real send engine, so merge fields, conditionals, and spintax resolve exactly as they will at send time. **Preview as** picks who it renders for: a built-in sample contact, one of the campaign's leads, or any contact you search for, so a custom field your list does not actually have shows up as an unresolved token instead of looking fine. The mailbox picker next to it adds that sender's signature and shows the From name recipients will see. When the campaign is known the preview also appends the opt-out footer and lists the files attached to the campaign. Malformed templates (an `{{if}}` with no `{{end}}`) are flagged before you start the campaign. See [Personalization](/guides/expressions/) for everything you can put in copy.
+### The body editor
+
+The toolbar carries undo and redo (`Ctrl+Z` and `Ctrl+Shift+Z`, or `Cmd` on a Mac), bold, italic, underline and strikethrough, a heading, bullet and numbered lists, links, images, the personalization menu, an AI block, a condition, spintax, and a form link. `Shift+Enter` puts a line break inside a paragraph rather than starting a new one.
+
+Pasting copy from Gmail, Outlook, Word or another email tool keeps its paragraphs, lists, links and emphasis. The blank-line scaffolding those tools ship (an empty `div` per gap, Word's spacer paragraphs) is dropped on the way in, because the editor already spaces paragraphs itself and keeping both renders every gap twice.
+
+#### Images
+
+The image button offers three ways in: upload a file, paste an address, or pick something already in the workspace library. You can also drag an image straight onto the body, or paste a screenshot from the clipboard.
+
+Uploads go to a library shared by the whole workspace, so a logo uploaded once is one click away in every campaign. PNG, JPG, GIF and WebP are accepted, up to 5 MB each, and they count against the same storage limit as attachments, shown under **Settings > Billing**.
+
+Select a placed image and a small bar appears over it: quarter, half or full column width (or the image's own size), left, center or right alignment, and the alt text. Alt text is worth filling in. Most clients block remote images until the reader allows them, so alt text is what the first look actually shows, and it is what the plain-text version of the email carries in the image's place.
+
+Deleting an image from the library also deletes the file, so it stops loading in mail already sent. The dashboard asks before it does.
+
+
+Cold email from a real person rarely has images. An image-heavy body reads as a marketing blast to filters that cannot read the picture, and the content check flags it. One signature logo or one product shot is usually the most a first touch should carry.
+
+
+#### HTML source
+
+The `>` button on the right of the toolbar swaps the body for its HTML. What you type there is what the step sends, merge fields and conditions included, so you can paste a template built elsewhere and send it as is.
+
+Switching back to the visual editor hands the markup to the editor's own structure, which keeps paragraphs, breaks, headings, lists, links, emphasis and images. Anything outside that (tables, for example) is named in a confirmation before it is dropped, and staying in HTML keeps it.
+
+### Preview and test
+
+The Preview tab renders through the real send engine, so merge fields, conditionals, and spintax resolve exactly as they will at send time. It draws the body the way a mail client would, in its own frame, rather than the way the editor does. **Preview as** picks who it renders for: a built-in sample contact, one of the campaign's leads, or any contact you search for, so a custom field your list does not actually have shows up as an unresolved token instead of looking fine. The mailbox picker next to it adds that sender's signature and shows the From name recipients will see. When the campaign is known the preview also appends the opt-out footer and lists the files attached to the campaign. Malformed templates (an `{{if}}` with no `{{end}}`) are flagged before you start the campaign. See [Personalization](/guides/expressions/) for everything you can put in copy.
**Send test** in the preview mails the saved step to an address of your choice through the chosen mailbox, rendered for the chosen contact and carrying the attachments, signature and opt-out footer. Save the step first: the test sends what is stored, not unsaved edits. Opens and clicks on a test are not tracked, and its opt-out link names nobody, so clicking it suppresses no one.
@@ -28,6 +56,8 @@ The Preview tab renders through the real send engine, so merge fields, condition
Apply a saved template to a step, or save a step as a reusable one from the composer. Templates carry their subject and body and live in your shared library.
+### Attachments
+
Upload attachments for a step below its composer by dragging and dropping files or clicking the upload area. A file uploaded there is sent with every email from that step and from no other step, and can be removed in the same place. Files attached to the campaign itself rather than to a step, which can only be added through the API, ride every step: they are listed under **Sent with every step** so each step shows everything it carries. Attachments count against your workspace storage limit, shown under **Settings > Billing**; an upload that would pass it is refused with `storage_limit_reached`, and two uploads racing for the last of the quota cannot both get in. Removing a step deletes the files scoped to it.
### Action steps
diff --git a/docs/content/docs/guides/workspace-export-import.mdx b/docs/content/docs/guides/workspace-export-import.mdx
index 42d4c03c..45ba98c8 100644
--- a/docs/content/docs/guides/workspace-export-import.mdx
+++ b/docs/content/docs/guides/workspace-export-import.mdx
@@ -19,7 +19,7 @@ The data is split into groups. Every export includes **Workspace**; the rest are
|-------|----------|
| Workspace | The organization, members, roles, teams, mailboxes, API keys, webhooks, and settings, including the website tracking site key. Always included |
| Contacts | Contacts, categories, segments with their manual overrides, forms with their images, submissions, personalized link tickets and funnel events, notes, activities, and the suppression list |
-| Campaigns | Campaigns, sequences, senders, linked segments, attachments, per-campaign settings, and each lead's step progress with its per-link clicks and per-event opens |
+| Campaigns | Campaigns, sequences, senders, linked segments, attachments, the email image library, per-campaign settings, and each lead's step progress with its per-link clicks and per-event opens |
| CRM | Pipelines, deals, tasks, and meeting bookings |
| Automations | Automations, connected integrations, and lead sync sources |
| Assistant | Assistant sessions and messages, skills, MCP servers, and AI settings |
@@ -104,6 +104,7 @@ An import runs as one transaction. If anything fails, nothing lands and the work
- **Reconnect any mailbox that needs it.** Mailboxes without credentials show as needing a reconnect in the mailbox list.
- **Point your tracking domain at the new instance.** Click links already delivered keep resolving as long as the domain follows.
+- **Keep the old instance's image host reachable for a while, or re-place the images.** The image library and its files travel, but mail already sent carries the old instance's address inside each ``, so those images keep loading from there. Bodies you edit after the move pick up the new address.
- **Repoint your forms domain too.** A custom forms domain travels with the archive, but its verification does not: the record still points at the old instance. Update the `CNAME`, and the hourly re-check picks it up. Until then form links fall back to the shared host rather than breaking.
- **Check campaign schedules.** Per-contact progress travels, so a running campaign resumes at the step it reached rather than restarting.
- **Expect the daily send counters to be honoured.** Today's counts come across, so a mailbox cannot double its volume by being migrated mid-day.
diff --git a/internal/api/handler/email_image.go b/internal/api/handler/email_image.go
new file mode 100644
index 00000000..300997b8
--- /dev/null
+++ b/internal/api/handler/email_image.go
@@ -0,0 +1,298 @@
+// Workspace image library for email bodies (issue #380) — upload, list, delete.
+//
+// Unlike an attachment, an image placed in the body is fetched by the
+// recipient's mail client with no session of ours, so the bytes are written as
+// a public object and the row keeps the URL the composer inserts. The same
+// per-plan storage quota that bounds attachments bounds these, under the same
+// lock, because both live in the same object store.
+
+package handler
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "image"
+ _ "image/gif"
+ _ "image/jpeg"
+ _ "image/png"
+ "io"
+ "net/http"
+ "path"
+ "strconv"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+ "github.com/google/uuid"
+
+ "github.com/warmbly/warmbly/internal/api/middleware"
+ "github.com/warmbly/warmbly/internal/errx"
+ "github.com/warmbly/warmbly/internal/infrastructure/storage"
+ "github.com/warmbly/warmbly/internal/models"
+ "github.com/warmbly/warmbly/internal/utils/paging"
+)
+
+const (
+ // Per-image cap. An email body image has to load on a phone over a slow
+ // connection, so this is deliberately far below the attachment ceiling.
+ emailImageMaxBytes int64 = 5 * 1024 * 1024
+ // Dimension backstop against a raw camera dump; the composer downscales
+ // before uploading, so this only catches a bypass of that path.
+ emailImageMaxDimension = 4000
+ // Default page size, and the ceiling a caller may ask for.
+ emailImageListLimit = 40
+ emailImageListMax = 100
+)
+
+// Formats every mail client renders. SVG is excluded on purpose: it is a
+// script-capable document served from our own public origin, and no mail client
+// renders it anyway.
+//
+// Keyed by what http.DetectContentType returns, never by what the client
+// declared, so a name is the only thing the upload gets to choose.
+var allowedEmailImageMIME = map[string]string{
+ "image/png": ".png",
+ "image/jpeg": ".jpg",
+ "image/gif": ".gif",
+ "image/webp": ".webp",
+}
+
+// UploadEmailImage — POST /email-images (multipart "file")
+func (h *Handler) UploadEmailImage(c *gin.Context) {
+ orgID := middleware.GetOrganizationID(c)
+ if orgID == nil {
+ errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
+ return
+ }
+ if h.Storage == nil {
+ errx.JSON(c, errx.New(errx.ServiceUnavailable, "object storage not configured"))
+ return
+ }
+ if h.EmailImageRepo == nil {
+ errx.JSON(c, errx.New(errx.ServiceUnavailable, "image library not available"))
+ return
+ }
+
+ // Cap the body before the first form field is read: that read parses the
+ // whole multipart payload.
+ c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, emailImageMaxBytes+(1<<20))
+
+ fh, err := c.FormFile("file")
+ if err != nil {
+ errx.JSON(c, errx.New(errx.BadRequest, "file is required"))
+ return
+ }
+ if fh.Size <= 0 || fh.Size > emailImageMaxBytes {
+ errx.JSON(c, errx.New(errx.BadRequest, fmt.Sprintf("the image must be between 1 byte and %d MB", mb(emailImageMaxBytes))))
+ return
+ }
+
+ src, err := fh.Open()
+ if err != nil {
+ errx.JSON(c, errx.InternalError())
+ return
+ }
+ defer src.Close()
+ buf := &bytes.Buffer{}
+ if _, err := io.Copy(buf, src); err != nil {
+ errx.JSON(c, errx.InternalError())
+ return
+ }
+ body := buf.Bytes()
+
+ // The sniff decides, not the client-declared type: this object is served
+ // back from our own public origin.
+ mimeType := http.DetectContentType(body)
+ ext, ok := allowedEmailImageMIME[mimeType]
+ if !ok {
+ errx.JSON(c, errx.New(errx.BadRequest, "the image must be a PNG, JPG, GIF or WebP"))
+ return
+ }
+ cfg, _, err := image.DecodeConfig(bytes.NewReader(body))
+ if err != nil {
+ // WebP has no decoder registered, so its dimensions stay unknown
+ // rather than refusing a format every mail client renders.
+ if mimeType != "image/webp" {
+ errx.JSON(c, errx.New(errx.BadRequest, "the image could not be parsed"))
+ return
+ }
+ cfg = image.Config{}
+ }
+ if cfg.Width > emailImageMaxDimension || cfg.Height > emailImageMaxDimension {
+ errx.JSON(c, errx.New(errx.BadRequest, fmt.Sprintf("the image must be %dpx or smaller on each side", emailImageMaxDimension)))
+ return
+ }
+
+ filename := emailImageFilename(fh.Filename, ext)
+ key := models.EmailImageObjectKey(*orgID, ext)
+ stored, xerr := putPublicObject(c.Request.Context(), h.Storage, key, body, mimeType)
+ if xerr != nil {
+ errx.JSON(c, xerr)
+ return
+ }
+ url := absolutePublicURL(c, stored)
+
+ img := &models.EmailImage{
+ OrganizationID: *orgID,
+ Filename: filename,
+ MimeType: mimeType,
+ Size: fh.Size,
+ Width: cfg.Width,
+ Height: cfg.Height,
+ StorageKey: key,
+ URL: url,
+ }
+ if uid, perr := uuid.Parse(middleware.GetUserID(c)); perr == nil {
+ img.UserID = &uid
+ }
+
+ // The row is the reservation: written only if the org's total still fits
+ // once this image is counted, with the limit re-read under the lock. A
+ // refused image 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.EmailImageRepo.CreateWithinQuota(c.Request.Context(), img, limitFn)
+ if err != nil {
+ h.deleteObjectDetached(c.Request.Context(), key)
+ errx.JSON(c, errx.InternalError())
+ return
+ }
+ if !created {
+ h.deleteObjectDetached(c.Request.Context(), key)
+ errx.JSON(c, errx.StorageLimitReached(used, limit, fh.Size))
+ return
+ }
+
+ h.auditOrg(c, models.AuditActionCreate, models.AuditEntityEmailImage, &img.ID, nil, map[string]string{
+ "filename": filename,
+ })
+
+ c.JSON(http.StatusCreated, img)
+}
+
+// ListEmailImages — GET /email-images
+func (h *Handler) ListEmailImages(c *gin.Context) {
+ orgID := middleware.GetOrganizationID(c)
+ if orgID == nil {
+ errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
+ return
+ }
+ if h.EmailImageRepo == nil {
+ c.JSON(http.StatusOK, gin.H{"data": []models.EmailImage{}, "pagination": gin.H{"next_cursor": nil, "has_more": false}})
+ return
+ }
+ limit := emailImageListLimit
+ if raw := strings.TrimSpace(c.Query("limit")); raw != "" {
+ n, err := strconv.Atoi(raw)
+ if err != nil || n < 1 || n > emailImageListMax {
+ errx.JSON(c, errx.New(errx.BadRequest, fmt.Sprintf("limit must be between 1 and %d", emailImageListMax)))
+ return
+ }
+ limit = n
+ }
+ beforeAt, beforeID, xerr := paging.DecodeTimeCursor(c.Query("cursor"))
+ if xerr != nil {
+ errx.JSON(c, xerr)
+ return
+ }
+
+ // One extra row answers "is there a next page" without a second count.
+ imgs, err := h.EmailImageRepo.ListByOrg(c.Request.Context(), *orgID, limit+1, beforeAt, beforeID)
+ if err != nil {
+ errx.JSON(c, errx.InternalError())
+ return
+ }
+ var nextCursor *string
+ if len(imgs) > limit {
+ last := imgs[limit-1]
+ nextCursor = paging.EncodeTime(last.CreatedAt, last.ID)
+ imgs = imgs[:limit]
+ }
+ c.JSON(http.StatusOK, gin.H{
+ "data": imgs,
+ "pagination": gin.H{
+ "next_cursor": nextCursor,
+ "has_more": nextCursor != nil,
+ },
+ })
+}
+
+// DeleteEmailImage — DELETE /email-images/:id. The bytes go with the row, so
+// any email already sent with this image loses it; the dashboard says so first.
+func (h *Handler) DeleteEmailImage(c *gin.Context) {
+ orgID := middleware.GetOrganizationID(c)
+ if orgID == nil {
+ errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
+ return
+ }
+ if h.EmailImageRepo == nil {
+ errx.JSON(c, errx.New(errx.ServiceUnavailable, "image library not available"))
+ return
+ }
+ id, err := uuid.Parse(c.Param("id"))
+ if err != nil {
+ errx.JSON(c, errx.ErrUuid)
+ return
+ }
+ img, err := h.EmailImageRepo.GetByID(c.Request.Context(), id)
+ if err != nil {
+ errx.JSON(c, errx.InternalError())
+ return
+ }
+ // The route id is a raw path parameter, so ownership is proved here or an
+ // image of another workspace could be deleted.
+ if img == nil || img.OrganizationID != *orgID {
+ errx.JSON(c, errx.ErrNotFound)
+ return
+ }
+ // The object goes first. Deleting the row first and then failing here would
+ // leave a public URL that still loads while nothing counts its bytes
+ // against the quota and nothing remembers the key. This way a failure
+ // changes nothing and the caller can retry; the delete is idempotent, so a
+ // retry after the object is already gone still removes the row.
+ if h.Storage != nil {
+ if err := h.Storage.Delete(c.Request.Context(), img.StorageKey); err != nil && !errors.Is(err, storage.ErrNotFound) {
+ errx.JSON(c, errx.New(errx.ServiceUnavailable, "the image could not be removed from storage; try again"))
+ return
+ }
+ }
+ if err := h.EmailImageRepo.Delete(c.Request.Context(), id); err != nil {
+ errx.JSON(c, errx.InternalError())
+ return
+ }
+ h.auditOrg(c, models.AuditActionDelete, models.AuditEntityEmailImage, &id, nil, map[string]string{
+ "filename": img.Filename,
+ })
+ c.Status(http.StatusNoContent)
+}
+
+// absolutePublicURL makes a public object URL loadable from outside this
+// deployment. The filesystem backend returns a path when BLOB_PUBLIC_BASE_URL
+// is unset, and a recipient's mail client cannot resolve a path, so it is
+// anchored to this API's own origin.
+func absolutePublicURL(c *gin.Context, stored string) string {
+ if stored == "" || strings.HasPrefix(stored, "http://") || strings.HasPrefix(stored, "https://") {
+ return stored
+ }
+ return publicAPIBaseURL(c) + "/" + strings.TrimPrefix(stored, "/")
+}
+
+// emailImageFilename is the display name kept on the row: what the library
+// lists and what the alt text defaults to. The extension is forced to match
+// the sniffed type so the name never claims to be something the bytes are not.
+// It never reaches an object key, which is generated independently.
+func emailImageFilename(raw, ext string) string {
+ name := sanitizeFilename(raw)
+ name = strings.TrimSpace(strings.TrimSuffix(name, path.Ext(name)))
+ if name == "" {
+ name = "image"
+ }
+ return name + ext
+}
diff --git a/internal/api/handler/handler.go b/internal/api/handler/handler.go
index 0fab70f7..0b871842 100644
--- a/internal/api/handler/handler.go
+++ b/internal/api/handler/handler.go
@@ -294,6 +294,7 @@ type Handler struct {
UserRepo repository.UserRepository
OrgRepo repository.OrganizationRepository
AttachmentRepo repository.AttachmentRepository
+ EmailImageRepo repository.EmailImageRepository
StorageBackendRepo repository.StorageBackendRepository
CloudCredentialRepo repository.CloudCredentialRepository
ProvisioningTemplateRepo repository.ProvisioningTemplateRepository
diff --git a/internal/api/handler/public_object.go b/internal/api/handler/public_object.go
index c7196cb0..7a157c87 100644
--- a/internal/api/handler/public_object.go
+++ b/internal/api/handler/public_object.go
@@ -11,13 +11,15 @@ import (
"github.com/gin-gonic/gin"
"github.com/warmbly/warmbly/internal/infrastructure/storage"
+ "github.com/warmbly/warmbly/internal/models"
)
-// ServePublicObject streams a publicly-readable blob (avatar, org logo) from the
-// active storage backend. It exists for the filesystem backend, which has no
-// authority to serve objects itself; the S3 backend returns object-storage URLs
-// from PutPublic and never routes through here. Only the fixed public key
-// prefixes are served so this can't be used to read arbitrary stored objects.
+// ServePublicObject streams a publicly-readable blob (avatar, org logo, form
+// asset, email-body image) from the active storage backend. It exists for the
+// filesystem backend, which has no authority to serve objects itself; the S3
+// backend returns object-storage URLs from PutPublic and never routes through
+// here. Only the fixed public key prefixes are served so this can't be used to
+// read arbitrary stored objects.
func (h *Handler) ServePublicObject(c *gin.Context) {
key := strings.TrimPrefix(c.Param("key"), "/")
if key == "" || !isPublicKey(key) {
@@ -43,6 +45,9 @@ func (h *Handler) ServePublicObject(c *gin.Context) {
if ct := mime.TypeByExtension(filepath.Ext(key)); ct != "" {
c.Header("Content-Type", ct)
}
+ // These are user uploads served from our own origin, so the browser must
+ // not be free to decide they are something executable.
+ c.Header("X-Content-Type-Options", "nosniff")
// Keys are content-addressed (they carry an epoch suffix), so they're safe
// to cache immutably.
c.Header("Cache-Control", "public, max-age=31536000, immutable")
@@ -57,5 +62,5 @@ func isPublicKey(key string) bool {
return false
}
return strings.HasPrefix(key, "avatars/") || strings.HasPrefix(key, "oauth-app-logos/") ||
- strings.HasPrefix(key, "form-assets/")
+ strings.HasPrefix(key, "form-assets/") || strings.HasPrefix(key, models.EmailImageKeyPrefix)
}
diff --git a/internal/api/routes.go b/internal/api/routes.go
index 8fdbb8de..f3913978 100644
--- a/internal/api/routes.go
+++ b/internal/api/routes.go
@@ -1023,6 +1023,17 @@ func Run(
templates.POST("/score", m.RequireAccess(models.PermViewCampaigns, models.APIPermReadTemplates), h.ScoreTemplateContent)
}
+ // Workspace image library for email bodies. The bytes are public
+ // objects (a recipient's mail client fetches them with no session),
+ // so these routes only manage the library, not the reads.
+ emailImages := protected.Group("/email-images")
+ emailImages.Use(m.RequireOrganization(), m.RateLimitMiddleware(models.RateLimitWrite))
+ {
+ emailImages.GET("", m.RequireAccess(models.PermViewCampaigns, models.APIPermReadCampaigns), h.ListEmailImages)
+ emailImages.POST("", m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteCampaigns), h.UploadEmailImage)
+ emailImages.DELETE("/:id", m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteCampaigns), h.DeleteEmailImage)
+ }
+
// CRM routes (require org)
crmGroup := protected.Group("/crm")
crmGroup.Use(m.RequireOrganization(), m.RateLimitMiddleware(models.RateLimitWrite))
diff --git a/internal/app/orgtransfer/export.go b/internal/app/orgtransfer/export.go
index 9273edea..432b53fd 100644
--- a/internal/app/orgtransfer/export.go
+++ b/internal/app/orgtransfer/export.go
@@ -357,7 +357,18 @@ func (b *blobCollector) addURL(url, table, column string) {
}
// publicKeyPrefixes are the object-key prefixes reachable through a public URL.
-var publicKeyPrefixes = []string{"avatars/", "form-assets/"}
+var publicKeyPrefixes = []string{"avatars/", "form-assets/", models.EmailImageKeyPrefix}
+
+// isPublicBlobKey reports whether an object key belongs to one of those
+// prefixes, so import can restore it public-read rather than private.
+func isPublicBlobKey(key string) bool {
+ for _, prefix := range publicKeyPrefixes {
+ if strings.HasPrefix(key, prefix) {
+ return true
+ }
+ }
+ return false
+}
// ---------- small JSON helpers ----------
diff --git a/internal/app/orgtransfer/import.go b/internal/app/orgtransfer/import.go
index 79f768a3..abbb068f 100644
--- a/internal/app/orgtransfer/import.go
+++ b/internal/app/orgtransfer/import.go
@@ -157,7 +157,7 @@ func (s *service) ImportFrom(
}
report(93, "restoring attachments")
- if warn := s.restoreBlobs(ctx, entries, manifest); len(warn) > 0 {
+ if warn := s.restoreBlobs(ctx, tx, orgID, entries, manifest); len(warn) > 0 {
result.Warnings = append(result.Warnings, warn...)
}
@@ -651,7 +651,7 @@ func (s *service) buildUserMap(ctx context.Context, m *Manifest, actor uuid.UUID
// restoreBlobs writes the archive's objects back into this instance's storage
// under their original keys, so the rows that reference them resolve.
-func (s *service) restoreBlobs(ctx context.Context, entries map[string]*zip.File, m *Manifest) []string {
+func (s *service) restoreBlobs(ctx context.Context, tx pgx.Tx, orgID uuid.UUID, entries map[string]*zip.File, m *Manifest) []string {
if len(m.Blobs) == 0 {
return nil
}
@@ -661,6 +661,7 @@ func (s *service) restoreBlobs(ctx context.Context, entries map[string]*zip.File
}
var failed int
+ var imageKeys, imageURLs []string
for _, b := range m.Blobs {
entry, ok := entries[b.Path]
if !ok {
@@ -672,16 +673,42 @@ func (s *service) restoreBlobs(ctx context.Context, entries map[string]*zip.File
failed++
continue
}
- err = s.blobs.Put(ctx, b.Key, rc, "")
+ // A key under a public prefix has to be written public-read again, or
+ // the avatars, form assets and email-body images that reference it by
+ // URL resolve to a 403 on an S3 backend after the move.
+ if isPublicBlobKey(b.Key) {
+ var url string
+ url, err = s.blobs.PutPublic(ctx, b.Key, rc, "")
+ if err == nil && url != "" && strings.HasPrefix(b.Key, models.EmailImageKeyPrefix) {
+ imageKeys = append(imageKeys, b.Key)
+ imageURLs = append(imageURLs, url)
+ }
+ } else {
+ err = s.blobs.Put(ctx, b.Key, rc, "")
+ }
_ = rc.Close()
if err != nil {
failed++
}
}
- if failed > 0 {
- return []string{fmt.Sprintf("%d attachment(s) could not be restored to object storage.", failed)}
+
+ warnings := make([]string, 0, 2)
+ // An imported image row still carries the URL the source instance served
+ // it from, so a body composed here would point every recipient at the old
+ // host. The bytes now live here, so the row is repointed at this one.
+ if len(imageKeys) > 0 {
+ if _, err := tx.Exec(ctx, `
+ UPDATE email_images SET url = fresh.url
+ FROM (SELECT unnest($2::text[]) AS storage_key, unnest($3::text[]) AS url) AS fresh
+ WHERE email_images.organization_id = $1 AND email_images.storage_key = fresh.storage_key
+ `, orgID, imageKeys, imageURLs); err != nil {
+ warnings = append(warnings, "Email images were restored but still name the instance they came from; re-upload them if that host goes away.")
+ }
}
- return nil
+ if failed > 0 {
+ warnings = append(warnings, fmt.Sprintf("%d attachment(s) could not be restored to object storage.", failed))
+ }
+ return warnings
}
// resolveArchiveKey derives the archive key when the archive has secrets and a
diff --git a/internal/app/orgtransfer/spec.go b/internal/app/orgtransfer/spec.go
index 45fe32fb..fd8182e4 100644
--- a/internal/app/orgtransfer/spec.go
+++ b/internal/app/orgtransfer/spec.go
@@ -366,6 +366,13 @@ var Tables = []Table{
Scope: `campaign_id IN ` + orgCampaigns,
Blobs: []BlobColumn{{Column: "s3_key", Kind: BlobKindKey}},
},
+ {
+ Name: "email_images", Group: models.OrgDataGroupCampaigns,
+ Scope: scopeOrg,
+ Note: "The workspace's image library for email bodies. Bytes travel, are restored public-read under the same key, and " +
+ "the url is repointed at the destination; mail already sent keeps the address it was written with, so those images still load from the source.",
+ Blobs: []BlobColumn{{Column: "storage_key", Kind: BlobKindKey}},
+ },
{
Name: "campaign_senders", Group: models.OrgDataGroupCampaigns,
Scope: `campaign_id IN ` + orgCampaigns,
diff --git a/internal/errx/common.go b/internal/errx/common.go
index fbad866b..063c141e 100644
--- a/internal/errx/common.go
+++ b/internal/errx/common.go
@@ -211,11 +211,12 @@ func MailboxAllowanceReached(used, allowance int, paid bool) *Error {
fmt.Sprintf("This workspace holds %d of its %d mailboxes. Request an increase, or move to a plan with more daily sends.", used, allowance))
}
-// StorageLimitReached is the refusal for an attachment upload or copy that
-// would take the workspace past its storage quota.
+// StorageLimitReached is the refusal for an upload or copy (an attachment, or
+// an image for an email body) that would take the workspace past its storage
+// quota.
func StorageLimitReached(usedBytes, limitBytes, addingBytes int64) *Error {
const mb = 1024 * 1024
return NewWithIdentifier(BadRequest, "storage_limit_reached",
- fmt.Sprintf("Storage limit reached: %d MB of %d MB used, %d MB to add. Remove attachments or upgrade your plan.",
+ fmt.Sprintf("Storage limit reached: %d MB of %d MB used, %d MB to add. Remove attachments or images, or upgrade your plan.",
usedBytes/mb, limitBytes/mb, addingBytes/mb))
}
diff --git a/internal/infrastructure/db/migrations/000139_email_images.down.sql b/internal/infrastructure/db/migrations/000139_email_images.down.sql
new file mode 100644
index 00000000..98e82c63
--- /dev/null
+++ b/internal/infrastructure/db/migrations/000139_email_images.down.sql
@@ -0,0 +1 @@
+DROP TABLE IF EXISTS email_images;
diff --git a/internal/infrastructure/db/migrations/000139_email_images.up.sql b/internal/infrastructure/db/migrations/000139_email_images.up.sql
new file mode 100644
index 00000000..de9e42ba
--- /dev/null
+++ b/internal/infrastructure/db/migrations/000139_email_images.up.sql
@@ -0,0 +1,28 @@
+-- Images a workspace uploads to place inside an email body (issue #380). The
+-- bytes live in object storage under the public `email-images/` prefix, because
+-- the recipient's mail client fetches them with no session of ours; the row is
+-- the library the composer picks from and what the storage quota counts.
+--
+-- Deleting a row breaks the image in mail already sent, so the dashboard warns
+-- before it does. user_id is nullable so an offboarded member's uploads stay in
+-- the workspace library they belong to.
+CREATE TABLE IF NOT EXISTS email_images (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ organization_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+ user_id uuid REFERENCES users(id) ON DELETE SET NULL,
+ filename text NOT NULL,
+ mime_type text NOT NULL DEFAULT '',
+ size bigint NOT NULL DEFAULT 0,
+ width integer NOT NULL DEFAULT 0,
+ height integer NOT NULL DEFAULT 0,
+ storage_key text NOT NULL,
+ url text NOT NULL,
+ created_at timestamptz NOT NULL DEFAULT now()
+);
+
+-- The one query the library runs: this workspace's images, newest first.
+CREATE INDEX IF NOT EXISTS idx_email_images_org_created
+ ON email_images (organization_id, created_at DESC);
+
+COMMENT ON TABLE email_images IS
+ 'Workspace image library for email bodies. Bytes are public objects under email-images/; size counts against the org storage quota.';
diff --git a/internal/models/audit.go b/internal/models/audit.go
index 1b310aa9..4bae8498 100644
--- a/internal/models/audit.go
+++ b/internal/models/audit.go
@@ -72,17 +72,21 @@ const (
AuditEntityOrganizationMember AuditEntityType = "organization_member"
AuditEntityInvitation AuditEntityType = "invitation"
AuditEntityTemplate AuditEntityType = "template"
- AuditEntityWebhook AuditEntityType = "webhook"
- AuditEntityIntegration AuditEntityType = "integration"
- AuditEntityWarmupRoutingRule AuditEntityType = "warmup_routing_rule"
- AuditEntityFolder AuditEntityType = "folder"
- AuditEntityTag AuditEntityType = "tag"
- AuditEntityCategory AuditEntityType = "category"
- AuditEntitySegment AuditEntityType = "segment"
- AuditEntityForm AuditEntityType = "form"
- AuditEntitySubscription AuditEntityType = "subscription"
- AuditEntitySettings AuditEntityType = "settings"
- AuditEntitySuppression AuditEntityType = "suppression"
+ // AuditEntityEmailImage is one image in the workspace's library for email
+ // bodies. Audited so a teammate's upload or removal reaches every open
+ // composer through the spine.
+ AuditEntityEmailImage AuditEntityType = "email_image"
+ AuditEntityWebhook AuditEntityType = "webhook"
+ AuditEntityIntegration AuditEntityType = "integration"
+ AuditEntityWarmupRoutingRule AuditEntityType = "warmup_routing_rule"
+ AuditEntityFolder AuditEntityType = "folder"
+ AuditEntityTag AuditEntityType = "tag"
+ AuditEntityCategory AuditEntityType = "category"
+ AuditEntitySegment AuditEntityType = "segment"
+ AuditEntityForm AuditEntityType = "form"
+ AuditEntitySubscription AuditEntityType = "subscription"
+ AuditEntitySettings AuditEntityType = "settings"
+ AuditEntitySuppression AuditEntityType = "suppression"
// CRM entities
AuditEntityCRMPipeline AuditEntityType = "crm_pipeline"
diff --git a/internal/models/email_image.go b/internal/models/email_image.go
new file mode 100644
index 00000000..a27be7c1
--- /dev/null
+++ b/internal/models/email_image.go
@@ -0,0 +1,41 @@
+package models
+
+import (
+ "time"
+
+ "github.com/google/uuid"
+)
+
+// EmailImage is one image in a workspace's library for email bodies. The bytes
+// live in object storage under a public key (URL), because the recipient's mail
+// client fetches them with no session of ours; the row is what the composer
+// lists and what the storage quota counts.
+type EmailImage struct {
+ ID uuid.UUID `json:"id"`
+ OrganizationID uuid.UUID `json:"organization_id"`
+ UserID *uuid.UUID `json:"user_id,omitempty"`
+ Filename string `json:"filename"`
+ MimeType string `json:"mime_type"`
+ Size int64 `json:"size"`
+ Width int `json:"width"`
+ Height int `json:"height"`
+ StorageKey string `json:"-"`
+ URL string `json:"url"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+// EmailImageKeyPrefix is the public object-key prefix email-body images live
+// under. Public because a mail client loads them unauthenticated; the prefix is
+// what /public/*key and the org-archive blob collector both match on.
+const EmailImageKeyPrefix = "email-images/"
+
+// EmailImageObjectKey is where an email-body image's bytes live.
+//
+// The uploader's filename is deliberately NOT part of it. This key becomes a
+// URL inside an email, so a name like "acme-q3-pricing-internal.png" would be
+// read by every recipient; and a name is free text, so one holding ".." would
+// produce a key the public route refuses to serve. The name is kept on the row
+// instead, where the library lists it and the alt text defaults to it.
+func EmailImageObjectKey(orgID uuid.UUID, ext string) string {
+ return EmailImageKeyPrefix + orgID.String() + "/" + uuid.NewString() + ext
+}
diff --git a/internal/repository/email_image_quota_live_test.go b/internal/repository/email_image_quota_live_test.go
new file mode 100644
index 00000000..f7055a1e
--- /dev/null
+++ b/internal/repository/email_image_quota_live_test.go
@@ -0,0 +1,147 @@
+package repository
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/warmbly/warmbly/internal/models"
+)
+
+// Email-body images and campaign attachments share one storage quota, because
+// they share one object store. The reservation that enforces it is the insert
+// itself, under the org's quota lock, so what has to hold is that an image sees
+// the attachments already stored, that a refusal writes no row, and that the
+// attachment path sees images the same way in return.
+//
+// Run against the dev stack:
+//
+// WARMBLY_TEST_DB=postgres://warmbly:warmbly@localhost:15432/warmbly_dev?sslmode=disable \
+// go test ./internal/repository/ -run LiveEmailImageQuota -v
+func TestLiveEmailImageQuota(t *testing.T) {
+ handle, pool := liveContactDB(t)
+ f := newSharedOrgFixture(t, pool)
+ images := NewEmailImageRepository(handle)
+ attachments := NewAttachmentRepository(handle)
+ ctx := context.Background()
+
+ t.Cleanup(func() {
+ if _, err := pool.Exec(context.Background(), `DELETE FROM email_images WHERE organization_id = $1`, f.org); err != nil {
+ t.Errorf("cleanup email_images: %v", err)
+ }
+ if _, err := pool.Exec(context.Background(),
+ `DELETE FROM campaign_attachments WHERE campaign_id IN (SELECT id FROM campaigns WHERE organization_id = $1)`,
+ f.org); err != nil {
+ t.Errorf("cleanup campaign_attachments: %v", err)
+ }
+ })
+
+ limit := func(n int64) StorageLimitFunc {
+ return func(context.Context) (int64, error) { return n, nil }
+ }
+ newImage := func(name string, size int64) *models.EmailImage {
+ return &models.EmailImage{
+ OrganizationID: f.org,
+ UserID: &f.owner,
+ Filename: name,
+ MimeType: "image/png",
+ Size: size,
+ StorageKey: models.EmailImageObjectKey(f.org, name),
+ URL: "https://example.test/" + name,
+ }
+ }
+
+ // A 400-byte attachment leaves 600 of a 1000-byte quota.
+ att := &models.CampaignAttachment{
+ CampaignID: f.campaign,
+ UserID: f.owner,
+ Filename: "brief.pdf",
+ Size: 400,
+ MimeType: "application/pdf",
+ S3Key: "live/brief.pdf",
+ }
+ if created, _, _, err := attachments.CreateWithinQuota(ctx, att, f.org, limit(1000)); err != nil || !created {
+ t.Fatalf("attachment reservation: created=%v err=%v", created, err)
+ }
+
+ if created, used, _, err := images.CreateWithinQuota(ctx, newImage("logo.png", 500), limit(1000)); err != nil {
+ t.Fatalf("image reservation: %v", err)
+ } else if !created || used != 900 {
+ t.Fatalf("500-byte image into 600 bytes of room: created=%v used=%d, want true/900", created, used)
+ }
+
+ // 200 more would be 1100 against a 1000-byte quota.
+ created, used, applied, err := images.CreateWithinQuota(ctx, newImage("hero.png", 200), limit(1000))
+ if err != nil {
+ t.Fatalf("over-quota image: %v", err)
+ }
+ if created {
+ t.Error("an image past the quota was stored")
+ }
+ if used != 900 || applied != 1000 {
+ t.Errorf("refusal reported %d of %d, want 900 of 1000", used, applied)
+ }
+
+ list, err := images.ListByOrg(ctx, f.org, 10, time.Time{}, uuid.Nil)
+ if err != nil {
+ t.Fatalf("ListByOrg: %v", err)
+ }
+ if len(list) != 1 || list[0].Filename != "logo.png" {
+ t.Fatalf("library holds %d images (%v), want only the one that fit", len(list), list)
+ }
+
+ // The attachment path counts the image in return, so the two cannot each
+ // spend the same last bytes.
+ if total, serr := attachments.SumStorageUsedByOrg(ctx, f.org); serr != nil || total != 900 {
+ t.Errorf("shared total is %d (err %v), want 900", total, serr)
+ }
+
+ // Keyset paging: two more tiny images, then walk the library a page at a
+ // time. The cursor is (created_at, id), so rows sharing a timestamp still
+ // page without repeating or skipping one.
+ for _, name := range []string{"second.png", "third.png"} {
+ if created, _, _, cerr := images.CreateWithinQuota(ctx, newImage(name, 10), limit(1000)); cerr != nil || !created {
+ t.Fatalf("paging fixture %s: created=%v err=%v", name, created, cerr)
+ }
+ }
+ first, err := images.ListByOrg(ctx, f.org, 2, time.Time{}, uuid.Nil)
+ if err != nil || len(first) != 2 {
+ t.Fatalf("first page: %d rows, err %v", len(first), err)
+ }
+ next, err := images.ListByOrg(ctx, f.org, 2, first[1].CreatedAt, first[1].ID)
+ if err != nil {
+ t.Fatalf("second page: %v", err)
+ }
+ if len(next) != 1 || next[0].ID == first[0].ID || next[0].ID == first[1].ID {
+ t.Fatalf("second page returned %d rows and repeated the first page", len(next))
+ }
+ for _, extra := range next {
+ if derr := images.Delete(ctx, extra.ID); derr != nil {
+ t.Fatalf("paging cleanup: %v", derr)
+ }
+ }
+ for _, row := range first {
+ if row.ID == list[0].ID {
+ continue
+ }
+ if derr := images.Delete(ctx, row.ID); derr != nil {
+ t.Fatalf("paging cleanup: %v", derr)
+ }
+ }
+
+ if err := images.Delete(ctx, list[0].ID); err != nil {
+ t.Fatalf("Delete: %v", err)
+ }
+ if got, gerr := images.GetByID(ctx, list[0].ID); gerr != nil || got != nil {
+ t.Errorf("deleted image still readable: %v (err %v)", got, gerr)
+ }
+ if total, serr := attachments.SumStorageUsedByOrg(ctx, f.org); serr != nil || total != 400 {
+ t.Errorf("total after delete is %d (err %v), want the attachment alone", total, serr)
+ }
+
+ // An image of another workspace is never in this one's library.
+ if _, err := images.GetByID(ctx, uuid.New()); err != nil {
+ t.Errorf("GetByID on an unknown id should be a miss, not an error: %v", err)
+ }
+}
diff --git a/internal/repository/pg_attachment.go b/internal/repository/pg_attachment.go
index 750d9b3f..4f91b50c 100644
--- a/internal/repository/pg_attachment.go
+++ b/internal/repository/pg_attachment.go
@@ -26,8 +26,9 @@ type AttachmentRepository interface {
// pointed at another campaign's step would never be sent by either.
StepBelongsToCampaign(ctx context.Context, campaignID, sequenceID uuid.UUID) (bool, error)
Delete(ctx context.Context, id uuid.UUID) error
- // SumStorageUsedByOrg totals the bytes of every attachment owned by the org
- // (joined through campaigns) — the basis for the per-plan storage quota.
+ // SumStorageUsedByOrg totals every stored byte the org owns — its campaign
+ // attachments plus its email-body image library — 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 the limit. The limit is resolved by limitFn AFTER the org's
@@ -44,22 +45,31 @@ type AttachmentRepository interface {
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.
+// calling transaction. Every writer that checks the quota (campaign_attachments
+// and email_images) takes it first, so the sum it reads cannot go stale before
+// its insert.
func LockStorageQuota(ctx context.Context, tx pgx.Tx, orgID uuid.UUID) error {
_, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtext('campaign_attachments'), hashtext($1::text))`, orgID.String())
return err
}
+// storageUsedSQL totals every stored byte one organization owns. Attachments
+// and email-body images share one quota because they share one object store,
+// so they are summed together and taken under the same lock.
+const storageUsedSQL = `
+ SELECT
+ (SELECT COALESCE(SUM(ca.size), 0)
+ FROM campaign_attachments ca
+ JOIN campaigns c ON c.id = ca.campaign_id
+ WHERE c.organization_id = $1)
+ +
+ (SELECT COALESCE(SUM(size), 0) FROM email_images WHERE organization_id = $1)
+`
+
// storageUsedTx is SumStorageUsedByOrg inside a transaction.
func storageUsedTx(ctx context.Context, tx pgx.Tx, orgID uuid.UUID) (int64, error) {
var total int64
- err := tx.QueryRow(ctx, `
- SELECT COALESCE(SUM(ca.size), 0)
- FROM campaign_attachments ca
- JOIN campaigns c ON c.id = ca.campaign_id
- WHERE c.organization_id = $1
- `, orgID).Scan(&total)
+ err := tx.QueryRow(ctx, storageUsedSQL, orgID).Scan(&total)
return total, err
}
@@ -185,11 +195,6 @@ func (r *attachmentRepository) Delete(ctx context.Context, id uuid.UUID) error {
func (r *attachmentRepository) SumStorageUsedByOrg(ctx context.Context, orgID uuid.UUID) (int64, error) {
var total int64
- err := r.DB.QueryRow(ctx, `
- SELECT COALESCE(SUM(ca.size), 0)
- FROM campaign_attachments ca
- JOIN campaigns c ON c.id = ca.campaign_id
- WHERE c.organization_id = $1
- `, orgID).Scan(&total)
+ err := r.DB.QueryRow(ctx, storageUsedSQL, orgID).Scan(&total)
return total, err
}
diff --git a/internal/repository/pg_email_image.go b/internal/repository/pg_email_image.go
new file mode 100644
index 00000000..adec2ce3
--- /dev/null
+++ b/internal/repository/pg_email_image.go
@@ -0,0 +1,128 @@
+package repository
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5"
+ "github.com/warmbly/warmbly/internal/infrastructure/db"
+ "github.com/warmbly/warmbly/internal/models"
+)
+
+// EmailImageRepository persists the workspace image library used inside email
+// bodies. Binary content lives in object storage under a public key; these rows
+// track ownership, size (for the shared storage quota) and the public URL the
+// composer inserts.
+type EmailImageRepository interface {
+ // CreateWithinQuota inserts the row only if the organization's total
+ // storage stays within the limit, under the same lock and against the same
+ // total as campaign attachments, so an image and an attachment racing each
+ // other cannot both pass. Returns created=false with the total it saw and
+ // the limit it applied when the image does not fit.
+ CreateWithinQuota(ctx context.Context, img *models.EmailImage, limitFn StorageLimitFunc) (created bool, used, limit int64, err error)
+ // ListByOrg keyset-paginates the library: rows strictly older than
+ // (beforeCreatedAt, beforeID), newest first. Pass zero values for the
+ // first page.
+ ListByOrg(ctx context.Context, orgID uuid.UUID, limit int, beforeCreatedAt time.Time, beforeID uuid.UUID) ([]models.EmailImage, error)
+ GetByID(ctx context.Context, id uuid.UUID) (*models.EmailImage, error)
+ Delete(ctx context.Context, id uuid.UUID) error
+}
+
+type emailImageRepository struct {
+ DB *db.DB
+}
+
+func NewEmailImageRepository(database *db.DB) EmailImageRepository {
+ return &emailImageRepository{DB: database}
+}
+
+const emailImageCols = `id, organization_id, user_id, filename, mime_type, size, width, height, storage_key, url, created_at`
+
+func scanEmailImage(row pgx.Row, i *models.EmailImage) error {
+ return row.Scan(
+ &i.ID, &i.OrganizationID, &i.UserID, &i.Filename, &i.MimeType,
+ &i.Size, &i.Width, &i.Height, &i.StorageKey, &i.URL, &i.CreatedAt,
+ )
+}
+
+func (r *emailImageRepository) CreateWithinQuota(ctx context.Context, img *models.EmailImage, limitFn StorageLimitFunc) (bool, int64, int64, error) {
+ tx, err := r.DB.Begin(ctx)
+ if err != nil {
+ return false, 0, 0, err
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+
+ if err := LockStorageQuota(ctx, tx, img.OrganizationID); err != nil {
+ return false, 0, 0, err
+ }
+ limit, err := limitFn(ctx)
+ if err != nil {
+ return false, 0, 0, err
+ }
+ used, err := storageUsedTx(ctx, tx, img.OrganizationID)
+ if err != nil {
+ return false, 0, limit, err
+ }
+ if used+img.Size > limit {
+ return false, used, limit, nil
+ }
+ if err := scanEmailImage(tx.QueryRow(ctx, `
+ INSERT INTO email_images (organization_id, user_id, filename, mime_type, size, width, height, storage_key, url)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
+ RETURNING `+emailImageCols,
+ img.OrganizationID, img.UserID, img.Filename, img.MimeType,
+ img.Size, img.Width, img.Height, img.StorageKey, img.URL,
+ ), img); err != nil {
+ return false, used, limit, err
+ }
+ return true, used + img.Size, limit, tx.Commit(ctx)
+}
+
+func (r *emailImageRepository) ListByOrg(ctx context.Context, orgID uuid.UUID, limit int, beforeCreatedAt time.Time, beforeID uuid.UUID) ([]models.EmailImage, error) {
+ if limit <= 0 || limit > 200 {
+ limit = 60
+ }
+ query := `SELECT ` + emailImageCols + `
+ FROM email_images WHERE organization_id = $1
+ ORDER BY created_at DESC, id DESC LIMIT $2`
+ args := []any{orgID, limit}
+ if !beforeCreatedAt.IsZero() {
+ query = `SELECT ` + emailImageCols + `
+ FROM email_images WHERE organization_id = $1 AND (created_at, id) < ($3, $4)
+ ORDER BY created_at DESC, id DESC LIMIT $2`
+ args = append(args, beforeCreatedAt, beforeID)
+ }
+ rows, err := r.DB.Query(ctx, query, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ out := make([]models.EmailImage, 0)
+ for rows.Next() {
+ var img models.EmailImage
+ if err := scanEmailImage(rows, &img); err != nil {
+ return nil, err
+ }
+ out = append(out, img)
+ }
+ return out, rows.Err()
+}
+
+func (r *emailImageRepository) GetByID(ctx context.Context, id uuid.UUID) (*models.EmailImage, error) {
+ img := &models.EmailImage{}
+ err := scanEmailImage(r.DB.QueryRow(ctx, `SELECT `+emailImageCols+` FROM email_images WHERE id = $1`, id), img)
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil, nil
+ }
+ return nil, err
+ }
+ return img, nil
+}
+
+func (r *emailImageRepository) Delete(ctx context.Context, id uuid.UUID) error {
+ _, err := r.DB.Exec(ctx, `DELETE FROM email_images WHERE id = $1`, id)
+ return err
+}
diff --git a/web/package.json b/web/package.json
index 3d645424..66c710ca 100644
--- a/web/package.json
+++ b/web/package.json
@@ -53,6 +53,7 @@
"@tiptap/extension-bold": "^3.8.0",
"@tiptap/extension-color": "^3.8.0",
"@tiptap/extension-document": "^3.8.0",
+ "@tiptap/extension-hard-break": "^3.8.0",
"@tiptap/extension-heading": "^3.8.0",
"@tiptap/extension-highlight": "^3.8.0",
"@tiptap/extension-image": "^3.8.0",
@@ -67,6 +68,7 @@
"@tiptap/extension-text": "^3.8.0",
"@tiptap/extension-text-style": "^3.8.0",
"@tiptap/extension-underline": "^3.8.0",
+ "@tiptap/extensions": "^3.8.0",
"@tiptap/pm": "^3.9.1",
"@tiptap/react": "^3.8.0",
"@types/papaparse": "^5.3.16",
diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml
index d2aff84a..da6cb51e 100644
--- a/web/pnpm-lock.yaml
+++ b/web/pnpm-lock.yaml
@@ -128,6 +128,9 @@ importers:
'@tiptap/extension-document':
specifier: ^3.8.0
version: 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))
+ '@tiptap/extension-hard-break':
+ specifier: ^3.8.0
+ version: 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))
'@tiptap/extension-heading':
specifier: ^3.8.0
version: 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))
@@ -170,6 +173,9 @@ importers:
'@tiptap/extension-underline':
specifier: ^3.8.0
version: 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))
+ '@tiptap/extensions':
+ specifier: ^3.8.0
+ version: 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0)
'@tiptap/pm':
specifier: ^3.9.1
version: 3.11.0
@@ -1791,6 +1797,11 @@ packages:
'@tiptap/core': ^3.11.0
'@tiptap/pm': ^3.11.0
+ '@tiptap/extension-hard-break@3.11.0':
+ resolution: {integrity: sha512-NJEHTj++kFOayQXKSQSi9j9eAG33eSiJqai2pf4U+snW94fmb8cYLUurDmfYRe20O6EzBSX0X3GjVlkOz+5b7A==}
+ peerDependencies:
+ '@tiptap/core': ^3.11.0
+
'@tiptap/extension-heading@3.11.0':
resolution: {integrity: sha512-4Eo67Yo7vsYLkizcMoGdZAR9aHbC7FFTrqfNEd4Em3ajRi0iNqyWMaI90UCYlitDdRdqFlq/njWrMqBOLUgaWQ==}
peerDependencies:
@@ -1865,6 +1876,12 @@ packages:
peerDependencies:
'@tiptap/core': ^3.11.0
+ '@tiptap/extensions@3.11.0':
+ resolution: {integrity: sha512-g43beA73ZMLezez1st9LEwYrRHZ0FLzlsSlOZKk7sdmtHLmuqWHf4oyb0XAHol1HZIdGv104rYaGNgmQXr1ecQ==}
+ peerDependencies:
+ '@tiptap/core': ^3.11.0
+ '@tiptap/pm': ^3.11.0
+
'@tiptap/pm@3.11.0':
resolution: {integrity: sha512-plCQDLCZIOc92cizB8NNhBRN0szvYR3cx9i5IXo6v9Xsgcun8KHNcJkesc2AyeqdIs0BtOJZaqQ9adHThz8UDw==}
@@ -4967,6 +4984,10 @@ snapshots:
'@tiptap/pm': 3.11.0
optional: true
+ '@tiptap/extension-hard-break@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))':
+ dependencies:
+ '@tiptap/core': 3.11.0(@tiptap/pm@3.11.0)
+
'@tiptap/extension-heading@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))':
dependencies:
'@tiptap/core': 3.11.0(@tiptap/pm@3.11.0)
@@ -5028,6 +5049,11 @@ snapshots:
dependencies:
'@tiptap/core': 3.11.0(@tiptap/pm@3.11.0)
+ '@tiptap/extensions@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0)':
+ dependencies:
+ '@tiptap/core': 3.11.0(@tiptap/pm@3.11.0)
+ '@tiptap/pm': 3.11.0
+
'@tiptap/pm@3.11.0':
dependencies:
prosemirror-changeset: 2.3.1
diff --git a/web/src/app/app/audit/page.tsx b/web/src/app/app/audit/page.tsx
index 9d75fdca..67acdbe2 100644
--- a/web/src/app/app/audit/page.tsx
+++ b/web/src/app/app/audit/page.tsx
@@ -54,7 +54,7 @@ const ACTIONS: AuditAction[] = [
];
const ENTITY_TYPES: AuditEntityType[] = [
- "campaign", "contact", "email_account", "step", "template",
+ "campaign", "contact", "email_account", "step", "template", "email_image",
"api_key", "webhook", "integration", "warmup_routing_rule",
"organization", "organization_member", "invitation",
"folder", "tag", "category", "subscription", "settings",
diff --git a/web/src/components/app/ai/RichTextAIEdit.tsx b/web/src/components/app/ai/RichTextAIEdit.tsx
index d212a473..4cb82d66 100644
--- a/web/src/components/app/ai/RichTextAIEdit.tsx
+++ b/web/src/components/app/ai/RichTextAIEdit.tsx
@@ -2,7 +2,8 @@
// editor). Same floating pill + AIEditPopover as the textarea host; the editor
// gives us real selection coordinates via coordsAtPos. The rewrite replaces
// the selected range and stays selected for review; Undo restores a pre-edit
-// HTML snapshot (the step editor runs without a history extension).
+// HTML snapshot, which reverts the whole rewrite in one step rather than
+// unwinding it through the editor's own history.
import React from "react";
import { createPortal } from "react-dom";
diff --git a/web/src/components/app/campaigns/sequences/EmailContentEditor.tsx b/web/src/components/app/campaigns/sequences/EmailContentEditor.tsx
index eb413507..4c7b07b7 100644
--- a/web/src/components/app/campaigns/sequences/EmailContentEditor.tsx
+++ b/web/src/components/app/campaigns/sequences/EmailContentEditor.tsx
@@ -18,6 +18,7 @@ import {
} from "lucide-react";
import toast from "react-hot-toast";
import RichTextEditor, { VariableMenu } from "./RichTextEditor";
+import EmailBody from "@/components/app/unibox/EmailBody";
import { useTemplatePreview } from "@/lib/api/hooks/app/campaigns/useTemplatePreview";
import type { TemplatePreview } from "@/lib/api/client/app/campaigns/previewTemplate";
import type Contact from "@/lib/api/models/app/contacts/Contact";
@@ -285,16 +286,20 @@ export default function EmailContentEditor({
Subject: {(serverPreview?.subject ?? renderPreview(subject)) || "—"}
-
${escapeHtml(serverPreview.body_plain)}`
- : '
Nothing to preview yet.
'),
- }}
- />
+ {/* The body renders in the same sandboxed frame the
+ inbox uses. The HTML source view lets an author put
+ anything in a body, so dropping it into the
+ dashboard DOM would let one member's markup restyle
+ the app, or run, for every teammate who opens the
+ preview. The frame carries no allow-scripts, and it
+ also makes the preview read the way a mail client
+ renders it rather than the way our editor does. */}
+
+ Nothing uploaded yet. Images you add here are reusable across every campaign.
+
+ ) : (
+
+ {images.map((img) => (
+
+
+
+
+ ))}
+
+ )}
+ {hasNextPage && (
+
+ )}
+
+
+ )}
+ ,
+ document.body,
+ )}
+
+ );
+}
+
+// selectedImage returns the image the caret has selected as a node, or null.
+// The bubble only exists for that selection, so clicking away dismisses it
+// without a listener of its own.
+function selectedImage(editor: Editor): { pos: number; attrs: Record } | null {
+ const sel = editor.state.selection;
+ if (!(sel instanceof NodeSelection) || sel.node.type.name !== "image") return null;
+ return { pos: sel.from, attrs: sel.node.attrs };
+}
+
+// The editor re-renders its host on every transaction (shouldRerenderOnTransaction),
+// so this reads the live selection on each render rather than subscribing again.
+export function ImageBubble({ editor }: { editor: Editor }) {
+ const [anchor, setAnchor] = React.useState<{ top: number; left: number } | null>(null);
+
+ const selection = selectedImage(editor);
+ const selectedPos = selection?.pos ?? null;
+
+ // Follow the image through scrolling and resizes, the same way the AI pill
+ // does, so the bubble never detaches from what it edits.
+ React.useEffect(() => {
+ if (selectedPos === null) {
+ setAnchor(null);
+ return;
+ }
+ const sync = () => {
+ try {
+ const box = editor.view.coordsAtPos(selectedPos);
+ setAnchor({ top: box.top, left: box.left });
+ } catch {
+ setAnchor(null);
+ }
+ };
+ sync();
+ window.addEventListener("scroll", sync, true);
+ window.addEventListener("resize", sync);
+ return () => {
+ window.removeEventListener("scroll", sync, true);
+ window.removeEventListener("resize", sync);
+ };
+ }, [selectedPos, editor]);
+
+ if (typeof document === "undefined" || !selection || !anchor) return null;
+
+ const align = (selection.attrs.align as ImageAlign) ?? "left";
+ const width = (selection.attrs.width as number | null) ?? null;
+ const alt = (selection.attrs.alt as string | null) ?? "";
+ // No focus() here on purpose: the alt-text field is part of this bar, and
+ // pulling focus back into the editor on every keystroke would make it
+ // impossible to type in. ProseMirror keeps the node selected regardless.
+ const set = (attrs: Record) => editor.commands.updateAttributes("image", attrs);
+
+ const alignBtn = (value: ImageAlign, Icon: typeof AlignLeftIcon, title: string) => (
+
+ );
+
+ return createPortal(
+ {
+ // Nothing in a floating bar may sit off-screen: on a narrow
+ // viewport an image near the right edge would push it out.
+ if (!el) return;
+ const overflow = el.getBoundingClientRect().right - window.innerWidth + 8;
+ if (overflow > 0) el.style.left = `${Math.max(8, anchor.left - overflow)}px`;
+ }}
+ className="flex items-center gap-1 rounded-md border border-slate-200 bg-white p-1 shadow-[0_12px_32px_-8px_rgba(15,23,42,0.18)]"
+ >
+ {IMAGE_SIZE_PRESETS.map((p) => (
+
+ ))}
+
+ {alignBtn("left", AlignLeftIcon, "Align left")}
+ {alignBtn("center", AlignCenterIcon, "Center")}
+ {alignBtn("right", AlignRightIcon, "Align right")}
+
+ set({ alt: e.target.value })}
+ placeholder="Alt text"
+ title="Shown when the recipient's client blocks images, and read aloud by screen readers"
+ className="h-6 w-32 rounded border border-slate-200 px-1.5 text-[11px] text-slate-800 outline-none focus:border-sky-400"
+ />
+
+ ,
+ document.body,
+ );
+}
diff --git a/web/src/components/app/campaigns/sequences/RichTextEditor.tsx b/web/src/components/app/campaigns/sequences/RichTextEditor.tsx
index c867ecec..99607bbf 100644
--- a/web/src/components/app/campaigns/sequences/RichTextEditor.tsx
+++ b/web/src/components/app/campaigns/sequences/RichTextEditor.tsx
@@ -1,8 +1,13 @@
// Rich email-body editor for campaign Steps, built on TipTap (no deprecated
// execCommand). Controlled by an HTML string; emits HTML on change. Ships a
-// house-theme toolbar (headings, bold/italic/underline/strike, lists, link), a
-// one-click {{variable}} inserter, and a spintax `{a|b}` helper. Personalization
-// tokens are just text, so they survive serialization untouched.
+// house-theme toolbar (undo/redo, headings, bold/italic/underline/strike,
+// lists, link, images), a one-click {{variable}} inserter, a spintax `{a|b}`
+// helper, and an HTML source view. Personalization tokens are just text, so
+// they survive serialization untouched.
+//
+// Paste is normalised on the way in (pasteHtml.ts): a message copied out of
+// Gmail, Outlook or Word brings its own blank-line scaffolding, which our own
+// paragraph margins would then render a second time.
import React from "react";
import { createPortal } from "react-dom";
@@ -16,6 +21,8 @@ import Underline from "@tiptap/extension-underline";
import Strike from "@tiptap/extension-strike";
import Heading from "@tiptap/extension-heading";
import Link from "@tiptap/extension-link";
+import HardBreak from "@tiptap/extension-hard-break";
+import { UndoRedo } from "@tiptap/extensions";
import { BulletList, OrderedList, ListItem } from "@tiptap/extension-list";
import {
BoldIcon,
@@ -34,14 +41,23 @@ import {
ChevronDownIcon,
SparklesIcon,
GitBranchIcon,
+ Undo2Icon,
+ Redo2Icon,
+ CodeIcon,
+ PencilLineIcon,
} from "lucide-react";
import { AnimatePresence, motion } from "framer-motion";
import useClickOutside from "@/hooks/useClickOutside";
import { useAnchoredFloating } from "@/hooks/useAnchoredFloating";
+import { useConfirm } from "@/hooks/context/confirm";
import RichTextAIEdit from "@/components/app/ai/RichTextAIEdit";
import RichTextAICaret from "@/components/app/ai/RichTextAICaret";
import { useForms } from "@/lib/api/hooks/app/forms";
import { WEBSITE_URL } from "@/lib/information";
+import { EmailImage } from "./nodes/EmailImageNode";
+import { ImageBubble, ImageMenu } from "./ImageControls";
+import { insertImage, isSupportedImageFile, useImageUpload } from "./imageUpload";
+import { normalizePastedHTML } from "./pasteHtml";
import { VariableNode } from "./nodes/VariableNode";
import { AIVariableNode } from "./nodes/AIVariableNode";
import { ConditionalNode } from "./nodes/ConditionalNode";
@@ -99,6 +115,31 @@ export default function RichTextEditor({
// conditionals behave exactly as in the full editor.
minimal?: boolean;
}) {
+ const confirm = useConfirm();
+ // The editor is created once, so anything a ProseMirror handler needs at
+ // paste/drop time is reached through a ref rather than a closure over the
+ // render that created it.
+ const editorRef = React.useRef(null);
+ const { run: uploadImage } = useImageUpload();
+ const uploadRef = React.useRef(uploadImage);
+ uploadRef.current = uploadImage;
+ const minimalRef = React.useRef(minimal);
+ minimalRef.current = minimal;
+
+ // Uploads an image file dropped or pasted into the body and places it,
+ // optionally at a document position (where it was dropped).
+ const placeImageFiles = React.useCallback(async (files: File[], at?: number) => {
+ if (!editorRef.current) return;
+ for (const file of files) {
+ const created = await uploadRef.current(file);
+ if (!created || !editorRef.current) continue;
+ if (typeof at === "number") editorRef.current.commands.setTextSelection(at);
+ insertImage(editorRef.current, { url: created.url, alt: created.filename });
+ }
+ }, []);
+ const placeRef = React.useRef(placeImageFiles);
+ placeRef.current = placeImageFiles;
+
const editor = useEditor({
extensions: [
Document,
@@ -108,38 +149,112 @@ export default function RichTextEditor({
Italic,
Underline,
Strike,
+ HardBreak,
Heading.configure({ levels: [2, 3] }),
BulletList,
OrderedList,
ListItem,
Link.configure({ openOnClick: false, autolink: true }),
+ EmailImage,
+ // Without this there is no undo stack at all: Ctrl+Z fell through
+ // to the browser, which cannot undo a ProseMirror transaction.
+ UndoRedo,
VariableNode,
AIVariableNode,
ConditionalNode,
FormLinkNode,
],
content: upgradeVariableTokens(html || ""),
+ // Toolbar state (active marks, undo availability, the selected image)
+ // is read from the editor during render, so it has to repaint on a
+ // caret move, not only on a keystroke.
+ shouldRerenderOnTransaction: true,
editorProps: {
attributes: {
class: `tiptap-body ${
minimal ? "min-h-[68px] text-[13px]" : "min-h-[260px] px-3 py-2.5 text-[13px]"
} leading-relaxed text-slate-800 focus:outline-none`,
},
+ transformPastedHTML: (pasted) => normalizePastedHTML(pasted),
+ handlePaste: (_view, event) => {
+ if (minimalRef.current) return false;
+ const files = Array.from(event.clipboardData?.files ?? []).filter(isSupportedImageFile);
+ if (files.length === 0) return false;
+ event.preventDefault();
+ void placeRef.current(files);
+ return true;
+ },
+ handleDrop: (view, event, _slice, moved) => {
+ // `moved` is the editor's own content being dragged inside it.
+ if (minimalRef.current || moved) return false;
+ const dt = event instanceof DragEvent ? event.dataTransfer : null;
+ const files = Array.from(dt?.files ?? []).filter(isSupportedImageFile);
+ if (files.length === 0) return false;
+ event.preventDefault();
+ const at = view.posAtCoords({ left: event.clientX, top: event.clientY })?.pos;
+ void placeRef.current(files, at);
+ return true;
+ },
},
onUpdate: ({ editor }) => onChange(editor.getHTML()),
});
+ editorRef.current = editor;
+
+ // HTML source view. null = the visual editor; a string = the source the
+ // textarea holds, which is the value being saved while it is open.
+ const [source, setSource] = React.useState(null);
+ // The last value the source view emitted, so an incoming html prop can be
+ // told apart from the echo of our own keystroke.
+ const emittedSource = React.useRef(null);
// Keep the editor in sync when the value changes from outside (template
// applied, step switched, reset) without clobbering the user's caret on
// their own edits.
React.useEffect(() => {
if (!editor) return;
+ // While the source view is open it owns the value, so its own echo is
+ // ignored. A template applied over it is not an echo, and adopting it
+ // is the only way the textarea does not silently discard it.
+ if (source !== null) {
+ if (html !== emittedSource.current) {
+ emittedSource.current = html;
+ setSource(prettyHTML(html || ""));
+ }
+ return;
+ }
const current = editor.getHTML();
const incoming = upgradeVariableTokens(html || "");
if (incoming !== current) {
editor.commands.setContent(incoming, { emitUpdate: false });
}
- }, [html, editor]);
+ }, [html, editor, source]);
+
+ // Leaving the source view hands the markup back to the schema, which keeps
+ // only what it can represent. Anything it would drop is named first, while
+ // undoing the switch is still one click.
+ const toggleSource = () => {
+ if (!editor) return;
+ if (source === null) {
+ emittedSource.current = html;
+ setSource(prettyHTML(editor.getHTML()));
+ return;
+ }
+ const apply = () => {
+ editor.commands.setContent(upgradeVariableTokens(source), { emitUpdate: true });
+ emittedSource.current = null;
+ setSource(null);
+ };
+ const dropped = unsupportedTags(source);
+ if (dropped.length > 0) {
+ confirm.show(
+ `The visual editor cannot hold ${dropped.map((t) => `<${t}>`).join(", ")}. ` +
+ "Switching back removes those tags and keeps the text inside them. Stay in HTML to keep them.",
+ apply,
+ );
+ return;
+ }
+ apply();
+ };
if (!editor) return null;
@@ -166,26 +281,104 @@ export default function RichTextEditor({
return (
-
-
-
- {placeholder && editor.isEmpty && (
-
- {placeholder}
-
- )}
-
- {/* Select text → floating "Edit with AI" pill over the selection. */}
-
- {/* Collapsed caret → sparkle companion + ⌘J to write with AI. */}
-
- {/* Type `{{` → variable type-ahead at the caret. */}
-
+
+ {source !== null ? (
+ {
+ emittedSource.current = value;
+ setSource(value);
+ onChange(value);
+ }}
+ />
+ ) : (
+
+
+ {placeholder && editor.isEmpty && (
+
+ {placeholder}
+
+ )}
+
+ )}
+ {source === null && (
+ <>
+ {/* Select an image → size, alignment and alt text over it. */}
+
+ {/* Select text → floating "Edit with AI" pill over the selection. */}
+
+ {/* Collapsed caret → sparkle companion + ⌘J to write with AI. */}
+
+ {/* Type `{{` → variable type-ahead at the caret. */}
+
+ >
+ )}
);
}
-function Toolbar({ editor, variables, links = [] }: { editor: Editor; variables: string[]; links?: string[] }) {
+// HTMLSource is the raw-markup view. It is the value being saved while it is
+// open, so what the user types here is what the step sends.
+function HTMLSource({ value, onChange }: { value: string; onChange: (v: string) => void }) {
+ return (
+
+
+ );
+}
+
+// prettyHTML puts each block on its own line so the source view is readable.
+// The break only ever goes BETWEEN blocks, never inside one: whitespace there
+// is not content, so the round trip back into the editor is lossless.
+function prettyHTML(html: string): string {
+ return html.replace(/(<\/(?:p|div|h[1-6]|ul|ol|li|blockquote)>|]*>)(?=<)/gi, "$1\n").trim();
+}
+
+// The tags the visual editor's schema can hold. Anything else in the source
+// view is dropped the moment the editor parses it, so the user is told which
+// ones before that happens rather than after.
+const SCHEMA_TAGS = new Set([
+ "p", "br", "strong", "b", "em", "i", "u", "s", "strike", "del",
+ "h2", "h3", "ul", "ol", "li", "a", "img", "span", "div",
+]);
+
+function unsupportedTags(html: string): string[] {
+ const found = new Set();
+ for (const m of html.matchAll(/<\s*([a-zA-Z][a-zA-Z0-9]*)\b/g)) {
+ const tag = m[1].toLowerCase();
+ if (!SCHEMA_TAGS.has(tag)) found.add(tag);
+ }
+ return [...found].sort();
+}
+
+function Toolbar({
+ editor,
+ variables,
+ links = [],
+ sourceOpen,
+ onToggleSource,
+}: {
+ editor: Editor;
+ variables: string[];
+ links?: string[];
+ sourceOpen: boolean;
+ onToggleSource: () => void;
+}) {
const [linkOpen, setLinkOpen] = React.useState(false);
const [linkUrl, setLinkUrl] = React.useState("");
@@ -200,8 +393,45 @@ function Toolbar({ editor, variables, links = [] }: { editor: Editor; variables:
setLinkUrl("");
};
+ // Writing controls are the source view's business, not the toolbar's: the
+ // textarea holds markup, so a bold command there would be meaningless.
+ if (sourceOpen) {
+ return (
+
+
{linkOpen && (
void;
title: string;
+ disabled?: boolean;
children: React.ReactNode;
}) {
return (
@@ -342,9 +581,10 @@ function Btn({
type="button"
title={title}
aria-pressed={active}
+ disabled={disabled}
onMouseDown={(e) => e.preventDefault()}
onClick={onClick}
- className={`size-7 inline-flex items-center justify-center rounded transition-colors ${
+ className={`size-7 inline-flex items-center justify-center rounded transition-colors disabled:opacity-40 disabled:hover:bg-transparent ${
active ? "bg-sky-50 text-sky-700" : "text-slate-500 hover:text-slate-900 hover:bg-slate-100"
}`}
>
diff --git a/web/src/components/app/campaigns/sequences/emailPreview.ts b/web/src/components/app/campaigns/sequences/emailPreview.ts
index 514485f7..f3df7fb6 100644
--- a/web/src/components/app/campaigns/sequences/emailPreview.ts
+++ b/web/src/components/app/campaigns/sequences/emailPreview.ts
@@ -12,6 +12,13 @@ export { VARIABLES, SAMPLE };
// Derive plain text from the editor HTML so both alternatives ship populated.
export function htmlToPlain(html: string): string {
const withBreaks = html
+ // An image has no text of its own, so the plain-text alternative would
+ // silently lose whatever it carried. Its alt text stands in for it.
+ .replace(/]*>/gi, (tag) => {
+ const alt = tag.match(/\balt\s*=\s*"([^"]*)"/i) ?? tag.match(/\balt\s*=\s*'([^']*)'/i);
+ const text = (alt?.[1] ?? "").trim();
+ return text ? `[${text}]` : "";
+ })
.replace(/<\s*br\s*\/?>/gi, "\n")
.replace(/<\/\s*(p|div|h[1-6]|li|tr)\s*>/gi, "\n");
if (typeof document === "undefined") return withBreaks.replace(/<[^>]+>/g, "");
diff --git a/web/src/components/app/campaigns/sequences/imageUpload.ts b/web/src/components/app/campaigns/sequences/imageUpload.ts
new file mode 100644
index 00000000..bd27ec1b
--- /dev/null
+++ b/web/src/components/app/campaigns/sequences/imageUpload.ts
@@ -0,0 +1,69 @@
+// Shared image-upload plumbing for the campaign body editor (issue #380). The
+// toolbar menu, a pasted screenshot and a dropped file all go through one path,
+// so they report progress and failure identically.
+//
+// Uploads go to the workspace library because a body image is fetched by the
+// recipient's mail client, which has no session and cannot read a presigned
+// attachment URL.
+
+import React from "react";
+import toast from "react-hot-toast";
+import type { Editor } from "@tiptap/react";
+import { useUploadEmailImage } from "@/lib/api/hooks/app/campaigns/useEmailImages";
+import type EmailImage from "@/lib/api/models/app/campaigns/EmailImage";
+import type { AppError } from "@/lib/api/client/normalizeError";
+import buildError from "@/lib/helper/buildError";
+
+// Only what a mail client renders. Kept in step with the upload handler's
+// allowlist so the refusal happens before the request, not after it.
+export const ACCEPTED_IMAGE_TYPES = "image/png,image/jpeg,image/gif,image/webp";
+const ACCEPTED = new Set(["image/png", "image/jpeg", "image/jpg", "image/gif", "image/webp"]);
+
+export function isSupportedImageFile(file: File | null | undefined): boolean {
+ return !!file && ACCEPTED.has(file.type.toLowerCase());
+}
+
+// insertImage drops the image at the caret with the filename as its alt text,
+// so the plain-text alternative and a client with images off both still say
+// what was there.
+export function insertImage(editor: Editor, image: { url: string; alt?: string; width?: number | null }) {
+ editor
+ .chain()
+ .focus()
+ .insertContent({
+ type: "image",
+ attrs: {
+ src: image.url,
+ alt: image.alt ?? "",
+ width: image.width ?? null,
+ align: "left",
+ },
+ })
+ .run();
+}
+
+// useImageUpload is the one upload path: the toolbar menu, a pasted screenshot
+// and a dropped file all go through it, so they report progress and failure
+// identically.
+export function useImageUpload() {
+ const upload = useUploadEmailImage();
+ const run = React.useCallback(
+ async (file: File): Promise => {
+ if (!isSupportedImageFile(file)) {
+ toast.error("Images must be PNG, JPG, GIF or WebP.");
+ return null;
+ }
+ try {
+ return await toast.promise(upload.mutateAsync(file), {
+ loading: `Uploading ${file.name}…`,
+ success: "Image added.",
+ error: (e: AppError) => buildError(e),
+ });
+ } catch {
+ return null;
+ }
+ },
+ [upload],
+ );
+ return { run, isUploading: upload.isPending };
+}
diff --git a/web/src/components/app/campaigns/sequences/nodes/EmailImageNode.ts b/web/src/components/app/campaigns/sequences/nodes/EmailImageNode.ts
new file mode 100644
index 00000000..db29392e
--- /dev/null
+++ b/web/src/components/app/campaigns/sequences/nodes/EmailImageNode.ts
@@ -0,0 +1,72 @@
+// The node for the campaign body editor (issue #380).
+//
+// Mail clients ignore stylesheets and half of them ignore
Hi
");
+ expect(out).toBe("
Hi
");
+ });
+
+ it("collapses a run of breaks and strips the one a block ends with", () => {
+ expect(normalizePastedHTML("
One
Two
")).toBe("
One Two
");
+ });
+
+ it("keeps an image a mail client can load and removes one it cannot", () => {
+ expect(normalizePastedHTML('
')).toContain("https://x.test/a.png");
+ // A `cid:` part belongs to the message it was copied from, so the
+ // paragraph holding it is empty once it goes and drops with it.
+ expect(normalizePastedHTML('
')).toBe("");
+ });
+
+ it("keeps a block whose only content is an image", () => {
+ const out = normalizePastedHTML('
';
+ expect(normalizePastedHTML(chip)).toBe(chip);
+ });
+
+ it("leaves a copy from another TipTap editor untouched", () => {
+ // ProseMirror marks its own clipboard HTML and re-parses it exactly.
+ const html = '
One
';
+ expect(normalizePastedHTML(html)).toBe(html);
+ });
+});
+
+describe("htmlToPlain with images", () => {
+ it("stands an image in for its alt text", () => {
+ expect(htmlToPlain('
Look:
')).toBe(
+ "Look:\n[Our dashboard]",
+ );
+ });
+
+ it("drops an image with no alt text rather than leaving brackets", () => {
+ expect(htmlToPlain('
Hi
')).toBe("Hi");
+ });
+});
diff --git a/web/src/components/app/campaigns/sequences/pasteHtml.ts b/web/src/components/app/campaigns/sequences/pasteHtml.ts
new file mode 100644
index 00000000..eec50bc5
--- /dev/null
+++ b/web/src/components/app/campaigns/sequences/pasteHtml.ts
@@ -0,0 +1,151 @@
+// Paste normaliser for the campaign body editor (issue #380).
+//
+// Copying a message out of Gmail, Outlook or Word brings its own idea of a
+// blank line with it: Gmail writes one as `
`, Word as an empty
+// `
` holding ` `. Our paragraphs already carry their
+// own bottom margin, so pasting those verbatim renders every gap twice — the
+// reported double spacing. This strips the source's spacing scaffolding (and
+// its editor cruft) and leaves the structure: paragraphs, breaks, lists,
+// headings, links, images and the inline marks the schema keeps.
+
+// Elements whose content must not survive a paste: a