mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-09 16:04:41 +00:00
Merge pull request #391 from warmbly/fix/issue-380
fix: images, paste spacing, undo and HTML source in the campaign body editor
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 `<img src>`; 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 |
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 | `<data root>/postgres` |
|
||||
| `WARMBLY_BLOBS` | Message bodies, attachments, avatars and logos | `<data root>/blobs` |
|
||||
| `WARMBLY_BLOBS` | Message bodies, attachments, avatars, logos and email body images | `<data root>/blobs` |
|
||||
| `WARMBLY_NATS_DATA` | The event bus's JetStream state | `<data root>/nats` |
|
||||
| `WARMBLY_REDIS_DATA` | Cache and rate-limit counters. Disposable | `<data root>/redis` |
|
||||
| `WARMBLY_WORKER_STATE` | A worker's own id and sync cursors. Disposable | `<data root>/worker` |
|
||||
|
||||
@@ -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.
|
||||
|
||||
<Callout type="warn" title="Images cost you deliverability">
|
||||
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.
|
||||
</Callout>
|
||||
|
||||
#### 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.
|
||||
</Callout>
|
||||
|
||||
### 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
|
||||
|
||||
@@ -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 `<img src>`, 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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 ----------
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS email_images;
|
||||
@@ -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.';
|
||||
+15
-11
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Generated
+26
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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({
|
||||
<span className="text-slate-400">Subject: </span>
|
||||
<span className="text-slate-800">{(serverPreview?.subject ?? renderPreview(subject)) || "—"}</span>
|
||||
</div>
|
||||
<div
|
||||
className="tiptap-body min-h-[200px] px-3 py-2.5 text-[13px] leading-relaxed text-slate-800"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
(serverPreview?.body_html ?? linkifyUnsubscribe(renderPreview(bodyHtml))) ||
|
||||
(serverPreview?.body_plain
|
||||
? `<pre class="whitespace-pre-wrap font-sans">${escapeHtml(serverPreview.body_plain)}</pre>`
|
||||
: '<p class="text-slate-300">Nothing to preview yet.</p>'),
|
||||
}}
|
||||
/>
|
||||
{/* 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. */}
|
||||
<div className="min-h-[200px] px-3 py-2.5">
|
||||
<EmailBody
|
||||
html={serverPreview?.body_html ?? linkifyUnsubscribe(renderPreview(bodyHtml))}
|
||||
plain={serverPreview?.body_plain}
|
||||
/>
|
||||
</div>
|
||||
{(serverPreview?.attachments?.length ?? 0) > 0 && (
|
||||
<ul className="flex flex-wrap gap-1.5 border-t border-slate-200/70 px-3 py-2">
|
||||
{serverPreview!.attachments!.map((a) => (
|
||||
@@ -356,10 +361,6 @@ export default function EmailContentEditor({
|
||||
);
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function TabBtn({
|
||||
active,
|
||||
onClick,
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
// Image insertion and editing for the campaign body editor (issue #380).
|
||||
//
|
||||
// Two surfaces: a toolbar menu that uploads, takes a URL, or picks from the
|
||||
// workspace library, and a bubble over the selected image for size, alignment
|
||||
// and alt text. Uploads go to the 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 { createPortal } from "react-dom";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import {
|
||||
AlignCenterIcon,
|
||||
AlignLeftIcon,
|
||||
AlignRightIcon,
|
||||
ImageIcon,
|
||||
Loader2Icon,
|
||||
Trash2Icon,
|
||||
UploadCloudIcon,
|
||||
} from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { NodeSelection } from "@tiptap/pm/state";
|
||||
import useClickOutside from "@/hooks/useClickOutside";
|
||||
import { useAnchoredFloating } from "@/hooks/useAnchoredFloating";
|
||||
import { useConfirm } from "@/hooks/context/confirm";
|
||||
import { useEmailImages, useDeleteEmailImage } from "@/lib/api/hooks/app/campaigns/useEmailImages";
|
||||
import type EmailImage from "@/lib/api/models/app/campaigns/EmailImage";
|
||||
import formatBytes from "@/lib/helper/formatBytes";
|
||||
import { ACCEPTED_IMAGE_TYPES, insertImage, useImageUpload } from "./imageUpload";
|
||||
import { IMAGE_SIZE_PRESETS, type ImageAlign } from "./nodes/EmailImageNode";
|
||||
|
||||
export function ImageMenu({ editor }: { editor: Editor }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [url, setUrl] = React.useState("");
|
||||
const [dragging, setDragging] = React.useState(false);
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const fileRef = React.useRef<HTMLInputElement>(null);
|
||||
useClickOutside(ref, () => setOpen(false));
|
||||
const { setReference, setFloating, floatingStyle } = useAnchoredFloating(open, {
|
||||
placement: "bottom-start",
|
||||
gap: 6,
|
||||
maxHeight: true,
|
||||
});
|
||||
|
||||
// The library only loads while the menu is open: most steps never insert an
|
||||
// image and the list is a per-workspace read.
|
||||
const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } = useEmailImages(open);
|
||||
const images = React.useMemo(() => (data?.pages ?? []).flatMap((p) => p.data), [data]);
|
||||
const { run: upload, isUploading } = useImageUpload();
|
||||
const del = useDeleteEmailImage();
|
||||
const confirm = useConfirm();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Escape") return;
|
||||
// The delete confirmation sits above this menu, so Escape belongs
|
||||
// to it first: only the innermost layer closes.
|
||||
if (document.querySelector("[role='alertdialog']")) return;
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
};
|
||||
document.addEventListener("keydown", onKey, true);
|
||||
return () => document.removeEventListener("keydown", onKey, true);
|
||||
}, [open]);
|
||||
|
||||
const pick = async (files: FileList | File[] | null) => {
|
||||
const file = Array.from(files ?? [])[0];
|
||||
if (!file) return;
|
||||
const created = await upload(file);
|
||||
if (!created) return;
|
||||
insertImage(editor, { url: created.url, alt: created.filename });
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const applyUrl = () => {
|
||||
const u = url.trim();
|
||||
// https only: the dashboard is served over TLS, so a http:// image is
|
||||
// blocked as mixed content in the preview the author is looking at.
|
||||
if (!/^https:\/\//i.test(u)) {
|
||||
toast.error("Enter a full https:// image address.");
|
||||
return;
|
||||
}
|
||||
insertImage(editor, { url: u });
|
||||
setUrl("");
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const remove = (img: EmailImage) => {
|
||||
confirm.show(
|
||||
`Delete "${img.filename}" from the library? Emails already sent with it lose the image.`,
|
||||
async () => {
|
||||
await del.mutateAsync(img.id);
|
||||
toast.success("Image deleted.");
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
ref={(el) => setReference(el)}
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
title="Insert an image"
|
||||
aria-pressed={open}
|
||||
className={`size-7 inline-flex items-center justify-center rounded transition-colors ${
|
||||
open ? "bg-sky-50 text-sky-700" : "text-slate-500 hover:text-slate-900 hover:bg-slate-100"
|
||||
}`}
|
||||
>
|
||||
<ImageIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{typeof document !== "undefined" &&
|
||||
createPortal(
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
ref={setFloating}
|
||||
data-floating=""
|
||||
style={floatingStyle}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.12 }}
|
||||
className="z-[60] w-[340px] max-w-[calc(100vw-24px)] overflow-y-auto rounded-md border border-slate-200 bg-white shadow-[0_12px_32px_-8px_rgba(15,23,42,0.18)]"
|
||||
>
|
||||
<div className="border-b border-slate-100 px-3 py-2">
|
||||
<p className="text-[12px] font-medium text-slate-800">Insert an image</p>
|
||||
<p className="mt-0.5 text-[10.5px] text-slate-400">
|
||||
Cold email lands better with few images. One signature logo or product shot is
|
||||
plenty.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-2">
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
void pick(e.dataTransfer?.files ?? null);
|
||||
}}
|
||||
disabled={isUploading}
|
||||
className={`flex w-full flex-col items-center justify-center gap-1 rounded-md border border-dashed px-3 py-4 transition-colors ${
|
||||
dragging
|
||||
? "border-sky-400 bg-sky-50/60"
|
||||
: "border-slate-200 hover:border-sky-300 hover:bg-sky-50/40"
|
||||
} disabled:opacity-60`}
|
||||
>
|
||||
{isUploading ? (
|
||||
<Loader2Icon className="w-4 h-4 animate-spin text-sky-600" />
|
||||
) : (
|
||||
<UploadCloudIcon className="w-4 h-4 text-slate-400" />
|
||||
)}
|
||||
<span className="text-[11.5px] text-slate-600">
|
||||
{isUploading ? "Uploading…" : "Drop an image, or click to choose"}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400">PNG, JPG, GIF or WebP · up to 5 MB</span>
|
||||
</button>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept={ACCEPTED_IMAGE_TYPES}
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
void pick(e.target.files);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="px-2 pb-2">
|
||||
<div className="px-1 pb-1 text-[10px] uppercase tracking-[0.14em] text-slate-400">
|
||||
By address
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
applyUrl();
|
||||
}
|
||||
}}
|
||||
placeholder="https://…/logo.png"
|
||||
className="h-7 min-w-0 flex-1 rounded-md border border-slate-200 bg-white px-2 text-[12px] text-slate-900 placeholder:text-slate-400 outline-none focus:border-sky-400 focus:ring-2 focus:ring-sky-100"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={applyUrl}
|
||||
disabled={!url.trim()}
|
||||
className="h-7 shrink-0 rounded-md bg-sky-600 px-2.5 text-[11.5px] font-medium text-white transition-colors hover:bg-sky-700 disabled:opacity-50"
|
||||
>
|
||||
Insert
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-2 pb-2">
|
||||
<div className="px-1 pb-1 text-[10px] uppercase tracking-[0.14em] text-slate-400">
|
||||
Workspace library
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="px-1 py-2 text-[11.5px] text-slate-400">Loading…</div>
|
||||
) : images.length === 0 ? (
|
||||
<div className="px-1 py-2 text-[11.5px] text-slate-400">
|
||||
Nothing uploaded yet. Images you add here are reusable across every campaign.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid max-h-56 grid-cols-3 gap-1.5 overflow-y-auto">
|
||||
{images.map((img) => (
|
||||
<div key={img.id} className="group relative">
|
||||
<button
|
||||
type="button"
|
||||
title={`${img.filename} · ${formatBytes(img.size)}`}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
insertImage(editor, { url: img.url, alt: img.filename });
|
||||
setOpen(false);
|
||||
}}
|
||||
className="block aspect-square w-full overflow-hidden rounded-md border border-slate-200 bg-slate-50 transition-colors hover:border-sky-300"
|
||||
>
|
||||
<img
|
||||
src={img.url}
|
||||
alt={img.filename}
|
||||
loading="lazy"
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Delete from the library"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => remove(img)}
|
||||
className="absolute right-0.5 top-0.5 size-5 inline-flex items-center justify-center rounded bg-white/90 text-slate-400 opacity-100 transition-colors hover:text-rose-600 md:opacity-0 md:group-hover:opacity-100"
|
||||
>
|
||||
<Trash2Icon className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{hasNextPage && (
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => void fetchNextPage()}
|
||||
disabled={isFetchingNextPage}
|
||||
className="mt-1.5 h-6 w-full rounded text-[11.5px] font-medium text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900 disabled:opacity-50"
|
||||
>
|
||||
{isFetchingNextPage ? "Loading…" : "Show older"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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<string, unknown> } | 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<string, unknown>) => editor.commands.updateAttributes("image", attrs);
|
||||
|
||||
const alignBtn = (value: ImageAlign, Icon: typeof AlignLeftIcon, title: string) => (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
aria-pressed={align === value}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => set({ align: value })}
|
||||
className={`size-6 inline-flex items-center justify-center rounded transition-colors ${
|
||||
align === value ? "bg-sky-50 text-sky-700" : "text-slate-500 hover:bg-slate-100 hover:text-slate-900"
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-3 h-3" />
|
||||
</button>
|
||||
);
|
||||
|
||||
return createPortal(
|
||||
<motion.div
|
||||
data-floating=""
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.12 }}
|
||||
style={{ position: "fixed", top: Math.max(8, anchor.top - 40), left: anchor.left, zIndex: 60 }}
|
||||
ref={(el) => {
|
||||
// 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) => (
|
||||
<button
|
||||
key={p.label}
|
||||
type="button"
|
||||
title={p.title}
|
||||
aria-pressed={width === p.width}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => set({ width: p.width })}
|
||||
className={`h-6 px-1.5 rounded text-[11px] font-medium transition-colors ${
|
||||
width === p.width ? "bg-sky-50 text-sky-700" : "text-slate-500 hover:bg-slate-100 hover:text-slate-900"
|
||||
}`}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
<span className="mx-0.5 h-4 w-px bg-slate-200" />
|
||||
{alignBtn("left", AlignLeftIcon, "Align left")}
|
||||
{alignBtn("center", AlignCenterIcon, "Center")}
|
||||
{alignBtn("right", AlignRightIcon, "Align right")}
|
||||
<span className="mx-0.5 h-4 w-px bg-slate-200" />
|
||||
<input
|
||||
value={alt}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
title="Remove image"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => editor.chain().focus().deleteSelection().run()}
|
||||
className="size-6 inline-flex items-center justify-center rounded text-slate-400 transition-colors hover:bg-rose-50 hover:text-rose-600"
|
||||
>
|
||||
<Trash2Icon className="w-3 h-3" />
|
||||
</button>
|
||||
</motion.div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -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<Editor | null>(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<string | null>(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<string | null>(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 (
|
||||
<div className="rounded-md border border-slate-200 bg-white focus-within:border-sky-400 focus-within:ring-2 focus-within:ring-sky-100 transition-colors">
|
||||
<Toolbar editor={editor} variables={variables} links={links} />
|
||||
<div className="relative">
|
||||
<EditorContent editor={editor} />
|
||||
{placeholder && editor.isEmpty && (
|
||||
<p className="pointer-events-none absolute left-3 top-2.5 text-[13px] text-slate-300 select-none">
|
||||
{placeholder}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Select text → floating "Edit with AI" pill over the selection. */}
|
||||
<RichTextAIEdit editor={editor} />
|
||||
{/* Collapsed caret → sparkle companion + ⌘J to write with AI. */}
|
||||
<RichTextAICaret editor={editor} />
|
||||
{/* Type `{{` → variable type-ahead at the caret. */}
|
||||
<EditorSuggest editor={editor} links={links} />
|
||||
<Toolbar
|
||||
editor={editor}
|
||||
variables={variables}
|
||||
links={links}
|
||||
sourceOpen={source !== null}
|
||||
onToggleSource={toggleSource}
|
||||
/>
|
||||
{source !== null ? (
|
||||
<HTMLSource
|
||||
value={source}
|
||||
onChange={(value) => {
|
||||
emittedSource.current = value;
|
||||
setSource(value);
|
||||
onChange(value);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<EditorContent editor={editor} />
|
||||
{placeholder && editor.isEmpty && (
|
||||
<p className="pointer-events-none absolute left-3 top-2.5 text-[13px] text-slate-300 select-none">
|
||||
{placeholder}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{source === null && (
|
||||
<>
|
||||
{/* Select an image → size, alignment and alt text over it. */}
|
||||
<ImageBubble editor={editor} />
|
||||
{/* Select text → floating "Edit with AI" pill over the selection. */}
|
||||
<RichTextAIEdit editor={editor} />
|
||||
{/* Collapsed caret → sparkle companion + ⌘J to write with AI. */}
|
||||
<RichTextAICaret editor={editor} />
|
||||
{/* Type `{{` → variable type-ahead at the caret. */}
|
||||
<EditorSuggest editor={editor} links={links} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
spellCheck={false}
|
||||
placeholder="<p>Hi {{.FirstName}}, …</p>"
|
||||
className="min-h-[260px] w-full resize-y bg-white px-3 py-2.5 font-mono text-[12px] leading-relaxed text-slate-800 outline-none"
|
||||
/>
|
||||
<p className="border-t border-slate-200/70 px-3 py-1.5 text-[10.5px] text-slate-400">
|
||||
This is what the step sends. Merge fields, conditions and spintax all still work here.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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)>|<img\b[^>]*>)(?=<)/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<string>();
|
||||
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 (
|
||||
<div className="relative flex flex-wrap items-center gap-0.5 border-b border-slate-200/70 px-1.5 py-1">
|
||||
<span className="px-1.5 text-[10px] uppercase tracking-[0.14em] text-slate-400">HTML source</span>
|
||||
<div className="ml-auto">
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={onToggleSource}
|
||||
title="Back to the visual editor"
|
||||
className="h-7 px-2 inline-flex items-center gap-1.5 rounded text-[11.5px] font-medium text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900"
|
||||
>
|
||||
<PencilLineIcon className="w-3.5 h-3.5" />
|
||||
Visual
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-wrap items-center gap-0.5 border-b border-slate-200/70 px-1.5 py-1">
|
||||
<Btn
|
||||
onClick={() => editor.chain().focus().undo().run()}
|
||||
disabled={!editor.can().undo()}
|
||||
title="Undo (Ctrl+Z)"
|
||||
>
|
||||
<Undo2Icon className="w-3.5 h-3.5" />
|
||||
</Btn>
|
||||
<Btn
|
||||
onClick={() => editor.chain().focus().redo().run()}
|
||||
disabled={!editor.can().redo()}
|
||||
title="Redo (Ctrl+Shift+Z)"
|
||||
>
|
||||
<Redo2Icon className="w-3.5 h-3.5" />
|
||||
</Btn>
|
||||
<Divider />
|
||||
<Btn active={editor.isActive("bold")} onClick={() => editor.chain().focus().toggleBold().run()} title="Bold">
|
||||
<BoldIcon className="w-3.5 h-3.5" />
|
||||
</Btn>
|
||||
@@ -238,6 +468,7 @@ function Toolbar({ editor, variables, links = [] }: { editor: Editor; variables:
|
||||
>
|
||||
<Link2Icon className="w-3.5 h-3.5" />
|
||||
</Btn>
|
||||
<ImageMenu editor={editor} />
|
||||
<Divider />
|
||||
<VariableMenu
|
||||
onPick={(v) => (links.includes(v) ? insertLinkToken(editor, v) : insertToken(editor, v))}
|
||||
@@ -268,6 +499,12 @@ function Toolbar({ editor, variables, links = [] }: { editor: Editor; variables:
|
||||
</Btn>
|
||||
<FormMenu onPick={(publicId) => editor.chain().focus().insertFormLink(publicId).run()} />
|
||||
|
||||
<div className="ml-auto">
|
||||
<Btn onClick={onToggleSource} title="Edit the HTML source">
|
||||
<CodeIcon className="w-3.5 h-3.5" />
|
||||
</Btn>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{linkOpen && (
|
||||
<motion.div
|
||||
@@ -330,11 +567,13 @@ function Btn({
|
||||
active,
|
||||
onClick,
|
||||
title,
|
||||
disabled,
|
||||
children,
|
||||
}: {
|
||||
active?: boolean;
|
||||
onClick: () => 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"
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -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(/<img\b[^>]*>/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, "");
|
||||
|
||||
@@ -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<EmailImage | null> => {
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// The <img> node for the campaign body editor (issue #380).
|
||||
//
|
||||
// Mail clients ignore stylesheets and half of them ignore <style> blocks too,
|
||||
// so every layout decision has to survive as an inline style or an attribute on
|
||||
// the tag itself. Width is written as both (Outlook reads the attribute), and
|
||||
// alignment as auto margins on a block image, which is the one centring trick
|
||||
// every client honours.
|
||||
|
||||
import { mergeAttributes } from "@tiptap/core";
|
||||
import Image from "@tiptap/extension-image";
|
||||
|
||||
export type ImageAlign = "left" | "center" | "right";
|
||||
|
||||
// The width a body renders at in most mail clients; the size presets are
|
||||
// fractions of it, so "L" fills the column instead of overflowing it.
|
||||
export const EMAIL_BODY_WIDTH = 600;
|
||||
|
||||
export const IMAGE_SIZE_PRESETS: { label: string; title: string; width: number | null }[] = [
|
||||
{ label: "S", title: "Quarter width", width: Math.round(EMAIL_BODY_WIDTH * 0.25) },
|
||||
{ label: "M", title: "Half width", width: Math.round(EMAIL_BODY_WIDTH * 0.5) },
|
||||
{ label: "L", title: "Full width", width: EMAIL_BODY_WIDTH },
|
||||
{ label: "Auto", title: "The image's own size", width: null },
|
||||
];
|
||||
|
||||
function readWidth(el: HTMLElement): number | null {
|
||||
const raw = el.getAttribute("width") || el.style.width || "";
|
||||
const n = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(n) && n > 0 ? n : null;
|
||||
}
|
||||
|
||||
export const EmailImage = Image.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
width: {
|
||||
default: null,
|
||||
parseHTML: (el) => readWidth(el as HTMLElement),
|
||||
// Composed into the tag's style + width by renderHTML below.
|
||||
renderHTML: () => ({}),
|
||||
},
|
||||
// A stale height fights `height:auto` when a client scales the
|
||||
// image down to the screen, so it is never carried.
|
||||
height: {
|
||||
default: null,
|
||||
parseHTML: () => null,
|
||||
renderHTML: () => ({}),
|
||||
},
|
||||
align: {
|
||||
default: "left" as ImageAlign,
|
||||
parseHTML: (el) => {
|
||||
const a = (el as HTMLElement).getAttribute("data-align");
|
||||
return a === "center" || a === "right" ? a : "left";
|
||||
},
|
||||
renderHTML: (attrs) => ({ "data-align": (attrs.align as ImageAlign) ?? "left" }),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
renderHTML({ node, HTMLAttributes }) {
|
||||
const width = typeof node.attrs.width === "number" ? node.attrs.width : null;
|
||||
const align = (node.attrs.align as ImageAlign) ?? "left";
|
||||
const style = ["display:block", "max-width:100%", "height:auto", "border:0"];
|
||||
if (width) style.push(`width:${width}px`);
|
||||
style.push(align === "center" ? "margin:0 auto" : align === "right" ? "margin:0 0 0 auto" : "margin:0 auto 0 0");
|
||||
|
||||
const extra: Record<string, string> = { style: style.join(";") };
|
||||
if (width) extra.width = String(width);
|
||||
return ["img", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, extra)];
|
||||
},
|
||||
});
|
||||
|
||||
export default EmailImage;
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizePastedHTML } from "./pasteHtml";
|
||||
import { htmlToPlain } from "./emailPreview";
|
||||
|
||||
describe("normalizePastedHTML", () => {
|
||||
it("drops the empty block Gmail writes a blank line as", () => {
|
||||
// Our paragraphs carry their own bottom margin, so keeping this one
|
||||
// renders the gap twice — the double spacing in issue #380.
|
||||
const out = normalizePastedHTML("<div>One</div><div><br></div><div>Two</div>");
|
||||
expect(out).toBe("<div>One</div><div>Two</div>");
|
||||
});
|
||||
|
||||
it("drops Word's spacer paragraph and its namespaced tags", () => {
|
||||
const out = normalizePastedHTML(
|
||||
'<p class="MsoNormal">One<o:p></o:p></p>' +
|
||||
'<p class="MsoNormal"><o:p> </o:p></p>' +
|
||||
'<p class="MsoNormal">Two</p>',
|
||||
);
|
||||
expect(out).toBe('<p class="MsoNormal">One</p><p class="MsoNormal">Two</p>');
|
||||
});
|
||||
|
||||
it("removes a stylesheet instead of letting its CSS land as copy", () => {
|
||||
const out = normalizePastedHTML("<style>p{color:red}</style><p>Hi</p>");
|
||||
expect(out).toBe("<p>Hi</p>");
|
||||
});
|
||||
|
||||
it("collapses a run of breaks and strips the one a block ends with", () => {
|
||||
expect(normalizePastedHTML("<p>One<br><br>Two<br></p>")).toBe("<p>One<br>Two</p>");
|
||||
});
|
||||
|
||||
it("keeps an image a mail client can load and removes one it cannot", () => {
|
||||
expect(normalizePastedHTML('<p><img src="https://x.test/a.png"></p>')).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('<p><img src="cid:part1"></p>')).toBe("");
|
||||
});
|
||||
|
||||
it("keeps a block whose only content is an image", () => {
|
||||
const out = normalizePastedHTML('<div><img src="https://x.test/a.png"></div>');
|
||||
expect(out).toContain("<img");
|
||||
});
|
||||
|
||||
it("unwraps styling-only elements but keeps our merge-field chips", () => {
|
||||
expect(normalizePastedHTML('<p><font color="red">Hi</font></p>')).toBe("<p>Hi</p>");
|
||||
const chip = '<p><span data-var="">{{.FirstName}}</span></p>';
|
||||
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 = '<div data-pm-slice="1 1 []"><p>One</p><p><br></p></div>';
|
||||
expect(normalizePastedHTML(html)).toBe(html);
|
||||
});
|
||||
});
|
||||
|
||||
describe("htmlToPlain with images", () => {
|
||||
it("stands an image in for its alt text", () => {
|
||||
expect(htmlToPlain('<p>Look:</p><img src="https://x.test/a.png" alt="Our dashboard">')).toBe(
|
||||
"Look:\n[Our dashboard]",
|
||||
);
|
||||
});
|
||||
|
||||
it("drops an image with no alt text rather than leaving brackets", () => {
|
||||
expect(htmlToPlain('<p>Hi</p><img src="https://x.test/a.png">')).toBe("Hi");
|
||||
});
|
||||
});
|
||||
@@ -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 `<div><br></div>`, Word as an empty
|
||||
// `<p class=MsoNormal>` 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 <style> block's CSS is
|
||||
// text to the parser and would land in the body as copy.
|
||||
const DROP_WITH_CONTENT = new Set(["STYLE", "SCRIPT", "META", "LINK", "TITLE", "HEAD", "NOSCRIPT"]);
|
||||
|
||||
// Blocks that render their own gap in our editor, so an empty one is spacing
|
||||
// the source drew by hand and we draw with CSS.
|
||||
const SPACING_BLOCKS = new Set(["P", "DIV", "H1", "H2", "H3", "H4", "H5", "H6"]);
|
||||
|
||||
// An image src a mail client can actually load. A `cid:` part belongs to the
|
||||
// message it was copied from and a `data:` blob is stripped by most clients, so
|
||||
// both would only ever render as a broken image for the recipient.
|
||||
function isLoadableImage(src: string): boolean {
|
||||
return /^https?:\/\//i.test(src.trim());
|
||||
}
|
||||
|
||||
// isBlankBlock reports whether a block holds nothing but whitespace, <br> and
|
||||
// non-breaking spaces — the shapes every mail client writes a blank line as.
|
||||
function isBlankBlock(el: Element): boolean {
|
||||
if (el.querySelector("img, table, hr, iframe, video")) return false;
|
||||
// U+00A0 is the Word fills its spacer paragraphs with.
|
||||
return (el.textContent ?? "").replace(/[\s\u00a0]+/g, "") === "";
|
||||
}
|
||||
|
||||
// stripTrailingBreaks drops the <br>s a block ends with. Gmail closes a line
|
||||
// with one, and it renders as an extra blank line inside a paragraph that
|
||||
// already has a margin under it.
|
||||
function stripTrailingBreaks(el: Element) {
|
||||
let last = el.lastChild;
|
||||
while (last) {
|
||||
if (last.nodeType === Node.TEXT_NODE && (last.textContent ?? "").replace(/[\s\u00a0]+/g, "") === "") {
|
||||
const prev = last.previousSibling;
|
||||
last.parentNode?.removeChild(last);
|
||||
last = prev;
|
||||
continue;
|
||||
}
|
||||
if (last.nodeType === Node.ELEMENT_NODE && (last as Element).tagName === "BR") {
|
||||
const prev = last.previousSibling;
|
||||
last.parentNode?.removeChild(last);
|
||||
last = prev;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// collapseBreakRuns turns a run of consecutive <br>s into one. Two in a row is
|
||||
// how a mail client writes a paragraph break inside a block; keeping both would
|
||||
// stack an empty line on top of the margin our paragraphs already have.
|
||||
function collapseBreakRuns(root: ParentNode) {
|
||||
for (const br of Array.from(root.querySelectorAll("br"))) {
|
||||
let next = br.nextSibling;
|
||||
while (next) {
|
||||
if (next.nodeType === Node.TEXT_NODE && (next.textContent ?? "").replace(/[\s\u00a0]+/g, "") === "") {
|
||||
const after = next.nextSibling;
|
||||
next.parentNode?.removeChild(next);
|
||||
next = after;
|
||||
continue;
|
||||
}
|
||||
if (next.nodeType === Node.ELEMENT_NODE && (next as Element).tagName === "BR") {
|
||||
const after = next.nextSibling;
|
||||
next.parentNode?.removeChild(next);
|
||||
next = after;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// unwrap replaces an element with its children, keeping the content.
|
||||
function unwrap(el: Element) {
|
||||
const parent = el.parentNode;
|
||||
if (!parent) return;
|
||||
while (el.firstChild) parent.insertBefore(el.firstChild, el);
|
||||
parent.removeChild(el);
|
||||
}
|
||||
|
||||
// Chip spans our own nodes serialize to. A copy from one step's body into
|
||||
// another arrives as ordinary HTML, so these have to survive the span unwrap or
|
||||
// the merge fields, AI blocks, conditions and form links land as literal text.
|
||||
const KEEP_SPAN_ATTRS = ["data-var", "data-ai-var", "data-if", "data-form-link"];
|
||||
|
||||
export function normalizePastedHTML(html: string): string {
|
||||
if (!html || typeof window === "undefined" || typeof DOMParser === "undefined") return html;
|
||||
// A copy from inside a TipTap editor is already a document in our own
|
||||
// schema; ProseMirror marks it and re-parses it exactly. Nothing to fix.
|
||||
if (html.includes("data-pm-slice")) return html;
|
||||
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
const body = doc.body;
|
||||
if (!body) return html;
|
||||
|
||||
// Comments carry Word's conditional markup, which is a second copy of the
|
||||
// document the parser would otherwise leave lying in the output.
|
||||
const walker = doc.createTreeWalker(body, NodeFilter.SHOW_COMMENT);
|
||||
const comments: Comment[] = [];
|
||||
while (walker.nextNode()) comments.push(walker.currentNode as Comment);
|
||||
for (const c of comments) c.parentNode?.removeChild(c);
|
||||
|
||||
for (const el of Array.from(body.querySelectorAll("*"))) {
|
||||
if (!el.isConnected) continue;
|
||||
const tag = el.tagName;
|
||||
if (DROP_WITH_CONTENT.has(tag)) {
|
||||
el.remove();
|
||||
continue;
|
||||
}
|
||||
// Word's namespaced elements (<o:p>, <w:sdt>) hold nothing our editor
|
||||
// can use but do hold the that makes a spacer paragraph look
|
||||
// non-empty, so they go with their content.
|
||||
if (tag.includes(":")) {
|
||||
el.remove();
|
||||
continue;
|
||||
}
|
||||
if (tag === "IMG") {
|
||||
const src = el.getAttribute("src") ?? "";
|
||||
if (!isLoadableImage(src)) el.remove();
|
||||
continue;
|
||||
}
|
||||
// A <font> or <span> carries only styling our schema drops anyway;
|
||||
// unwrapping keeps the text and loses the wrapper.
|
||||
if (tag === "FONT" || tag === "SPAN" || tag === "CENTER") {
|
||||
if (KEEP_SPAN_ATTRS.some((a) => el.hasAttribute(a))) continue;
|
||||
unwrap(el);
|
||||
}
|
||||
}
|
||||
|
||||
collapseBreakRuns(body);
|
||||
|
||||
// Blank spacing blocks, innermost first, so a wrapper that only held one
|
||||
// becomes blank in turn and goes with it.
|
||||
for (const el of Array.from(body.querySelectorAll("p, div, h1, h2, h3, h4, h5, h6")).reverse()) {
|
||||
if (!el.isConnected) continue;
|
||||
if (SPACING_BLOCKS.has(el.tagName) && isBlankBlock(el)) el.remove();
|
||||
}
|
||||
|
||||
for (const el of Array.from(body.querySelectorAll("p, div, li, h1, h2, h3, h4, h5, h6, td"))) {
|
||||
stripTrailingBreaks(el);
|
||||
}
|
||||
|
||||
return body.innerHTML;
|
||||
}
|
||||
+15
-3
@@ -427,8 +427,10 @@ svg.loading circle {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Campaign Step composer body (TipTap editor + the rendered Preview pane).
|
||||
Tailwind preflight strips list markers + heading sizes, so restore them. */
|
||||
/* Campaign Step composer body (the TipTap editor surface). Tailwind preflight
|
||||
strips list markers + heading sizes, so restore them. The Preview tab is not
|
||||
styled here: it renders in the inbox's sandboxed frame, which carries its
|
||||
own document CSS. */
|
||||
.tiptap-body { word-break: break-word; }
|
||||
.tiptap-body p { margin: 0 0 0.6em; }
|
||||
.tiptap-body p:last-child { margin-bottom: 0; }
|
||||
@@ -442,8 +444,18 @@ svg.loading circle {
|
||||
.tiptap-body strong { font-weight: 600; }
|
||||
.tiptap-body:focus { outline: none; }
|
||||
|
||||
/* Body images. The sent markup carries its own inline width/margins (mail
|
||||
clients ignore stylesheets), so these rules only cover the editing surface:
|
||||
a sensible ceiling, and a visible ring while the image is the selection the
|
||||
floating size/alignment bar acts on. */
|
||||
.tiptap-body img { max-width: 100%; height: auto; border-radius: 2px; }
|
||||
.tiptap-body img.ProseMirror-selectednode {
|
||||
outline: 2px solid #38bdf8; /* sky-400 */
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* Merge-tag chips shown inside the composer editor. Only the node-view button
|
||||
carries .tpl-var; the resolved `<span data-var>` in the Preview pane is left
|
||||
carries .tpl-var; the resolved `<span data-var>` the preview renders is left
|
||||
unstyled on purpose so it reads as the plain value it will send as. */
|
||||
.tpl-var-wrap { vertical-align: baseline; }
|
||||
.tiptap-body .tpl-var {
|
||||
|
||||
@@ -357,6 +357,8 @@ export function useRealtimeEvents() {
|
||||
api_key: [['api-keys']],
|
||||
webhook: [['webhooks'], ['integrations', 'connections']],
|
||||
template: [['templates']],
|
||||
// The workspace image library the composer picks body images from.
|
||||
email_image: [['email-images']],
|
||||
organization: [['organizations']],
|
||||
// A risk transition changes send caps and warmup pool placement, so
|
||||
// the mailbox and analytics views move with it, not just the banner.
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type EmailImage from "@/lib/api/models/app/campaigns/EmailImage";
|
||||
import type { EmailImagePage } from "@/lib/api/models/app/campaigns/EmailImage";
|
||||
import Request from "../../Request";
|
||||
|
||||
// Workspace image library for email bodies. The upload is multipart (the file
|
||||
// rides the "file" field), mirroring campaign attachments; list and delete are
|
||||
// plain JSON requests. The list is keyset-paginated with an opaque cursor.
|
||||
|
||||
export async function listEmailImages(cursor?: string): Promise<EmailImagePage> {
|
||||
const res = await Request<EmailImagePage>({
|
||||
method: "GET",
|
||||
url: cursor ? `/email-images?cursor=${encodeURIComponent(cursor)}` : "/email-images",
|
||||
authorization: true,
|
||||
});
|
||||
return { data: res.data ?? [], pagination: res.pagination ?? { has_more: false } };
|
||||
}
|
||||
|
||||
export async function uploadEmailImage(file: File): Promise<EmailImage> {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file, file.name);
|
||||
return await Request<EmailImage>({
|
||||
method: "POST",
|
||||
url: "/email-images",
|
||||
data: fd,
|
||||
authorization: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteEmailImage(id: string): Promise<void> {
|
||||
await Request<void>({
|
||||
method: "DELETE",
|
||||
url: `/email-images/${id}`,
|
||||
authorization: true,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
listEmailImages,
|
||||
uploadEmailImage,
|
||||
deleteEmailImage,
|
||||
} from "@/lib/api/client/app/campaigns/emailImages";
|
||||
|
||||
// The audit spine invalidates ['email-images'] on every teammate's upload or
|
||||
// removal, so the library is live without a refetch interval.
|
||||
const key = ["email-images"];
|
||||
|
||||
export function useEmailImages(enabled = true) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: key,
|
||||
queryFn: ({ pageParam }) => listEmailImages(pageParam),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (last) => last.pagination.next_cursor ?? undefined,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUploadEmailImage() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (file: File) => uploadEmailImage(file),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: key }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteEmailImage() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => deleteEmailImage(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: key }),
|
||||
});
|
||||
}
|
||||
@@ -42,6 +42,7 @@ export type AuditEntityType =
|
||||
| "organization_member"
|
||||
| "invitation"
|
||||
| "template"
|
||||
| "email_image"
|
||||
| "webhook"
|
||||
| "integration"
|
||||
| "warmup_routing_rule"
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// One image in the workspace's library for email bodies. The bytes live in
|
||||
// object storage under a public URL, because the recipient's mail client
|
||||
// fetches them with no session of ours.
|
||||
export default interface EmailImage {
|
||||
id: string;
|
||||
filename: string;
|
||||
mime_type: string;
|
||||
size: number;
|
||||
// 0 when the format carries no dimensions we can read (WebP).
|
||||
width: number;
|
||||
height: number;
|
||||
url: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// One keyset page of the library, newest first.
|
||||
export interface EmailImagePage {
|
||||
data: EmailImage[];
|
||||
pagination: {
|
||||
next_cursor?: string | null;
|
||||
has_more?: boolean;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user