mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-21 00:02:19 +00:00
* feat: fix the six defects reported in issue #439 by mapping the IMAP UNAVAILABLE, INUSE and NONEXISTENT response codes to retry-level errors instead of a critical reconnect prompt, synthesising a stable no-msgid key so one message with no Message-ID header can no longer 400 the internal map endpoint and wedge every later sync pass with its cursors held, adding mailhtml.FromText and HasContent so an API or agent-created step with a plain body stops shipping the composer's empty div placeholder as its text/html part (derived on create and plain-only update, exposed as body_html on update_campaign_step, dropped at send and preview time, and refused at campaign start with empty_step_body), honouring sender_strategy='explicit' in ResolveCampaignSenderPool and ValidateCampaignReady so an emptied explicit pool parks the campaign instead of widening it to every mailbox in the workspace, making the paused_no_accounts auto-pause loud with an error log line, an error-level activity-feed entry and an org-scoped CAMPAIGN_PAUSED realtime pulse, gating the admin sign-in's Turnstile widget on GET /v1/auth/config so a self-host with CAPTCHA_PROVIDER=none is not locked out, and parsing NATS_URL down to its host:port so a credentialed bus URL no longer reports NATS down * feat: act on the self-review of the issue #439 fixes by dropping the campaign wizard's own escapeHtml body_html builder, which entity-escaped the quotes in a conditional and made the template fail to parse at send time, and letting the backend's FromText render that part instead so wizard-written steps also get their bare URLs linked for click tracking, correcting the docs and openapi description that claimed an explicit sender pool never falls back when it still unions its tags as migration 000013 designed, extracting the duplicated blank-HTML-part guard into dropBlankHTMLPart shared by the send path and the preview, and recording why the no-msgid key keeps the folder name despite a RENAME changing it * feat: address the CodeRabbit review on the issue #439 fixes by holding the admin sign-in's Turnstile widget unmounted until /v1/auth/config resolves so an instance with no route to Cloudflare cannot raise a widget error on a screen nobody submitted, failing StartCampaign closed when the sequence read errors rather than skipping both the malformed-template and empty-body refusals, giving TCPCheck the default port its protocol assumes so a portless NATS_URL is no longer reported down, leaving a URL that carries a merge field unanchored because the send path renders bodies with text/template and a quoted contact value would break out of the href, and correcting the sequences guide and the Campaign and CampaignUpdate openapi descriptions that named the wrong tag field
172 lines
6.7 KiB
Go
172 lines
6.7 KiB
Go
package tasks
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/rs/zerolog/log"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
"github.com/warmbly/warmbly/internal/pkg/mailhtml"
|
|
)
|
|
|
|
// EmailPreviewInput is one step's templates plus the context the send path
|
|
// would have: the contact to render for, the campaign (opt-out footer,
|
|
// attachments, plain-text rule) and the sending mailbox (signature, From).
|
|
// Campaign and Account are optional; without them the preview is templates only.
|
|
type EmailPreviewInput struct {
|
|
Subject string
|
|
BodyHTML string
|
|
BodyPlain string
|
|
Contact models.Contact
|
|
Campaign *models.Campaign
|
|
Account *models.Email
|
|
// SequenceID names the step being previewed, so the attachment list is the
|
|
// one that step sends. Zero lists the campaign-wide files only.
|
|
SequenceID uuid.UUID
|
|
}
|
|
|
|
// EmailPreviewFrom is the sender as the recipient will see it.
|
|
type EmailPreviewFrom struct {
|
|
Name string `json:"name"`
|
|
Email string `json:"email"`
|
|
}
|
|
|
|
// EmailPreviewAttachment is an attachment the send would carry, metadata only.
|
|
type EmailPreviewAttachment struct {
|
|
ID uuid.UUID `json:"id"`
|
|
Filename string `json:"filename"`
|
|
Size int64 `json:"size"`
|
|
MimeType string `json:"mime_type"`
|
|
}
|
|
|
|
// EmailPreview is the rendered message with everything the send path adds
|
|
// after the template: signature, opt-out footer, sender and attachments.
|
|
type EmailPreview struct {
|
|
TemplatePreview
|
|
From *EmailPreviewFrom `json:"from,omitempty"`
|
|
Attachments []EmailPreviewAttachment `json:"attachments,omitempty"`
|
|
}
|
|
|
|
// PreviewEmail renders a step the way the send path assembles it for one
|
|
// contact: template and spintax, then the plain-text rule, the mailbox
|
|
// signature and the opt-out footer, in send order. Tracking is left out since
|
|
// it only rewrites URLs. The opt-out link names no contact, so it can never
|
|
// suppress anyone if clicked.
|
|
func (s *tasksService) PreviewEmail(ctx context.Context, orgID uuid.UUID, in EmailPreviewInput) *EmailPreview {
|
|
unsubURL := PreviewUnsubscribeLink
|
|
var optOut *models.UnsubscribeSettings
|
|
textOnly := false
|
|
if in.Campaign != nil {
|
|
if s.unsubLinks != nil && s.unsubLinks.Enabled() {
|
|
unsubURL = s.unsubLinks.URL(orgID, in.Campaign.ID, uuid.Nil, time.Now())
|
|
}
|
|
settings := s.resolveOptOut(ctx, orgID, in.Campaign)
|
|
optOut = &settings
|
|
textOnly = in.Campaign.TextOnly
|
|
}
|
|
|
|
out := &EmailPreview{TemplatePreview: previewTemplatesWith(in.Subject, in.BodyHTML, in.BodyPlain, in.Contact, unsubURL)}
|
|
out.BodyHTML, out.BodyPlain = finishBody(out.BodyHTML, out.BodyPlain, textOnly, in.Account, optOut, unsubURL)
|
|
// Linted on what the author wrote, sized on what ships: the findings have
|
|
// to name the markup they can go and fix, but Gmail measures the wire. A
|
|
// plain-text campaign sends no HTML part at all, so there is no client
|
|
// left to be incompatible with and the notes would only be noise.
|
|
if !textOnly {
|
|
out.HTMLFindings = mailhtml.Lint(in.BodyHTML, len(out.BodyHTML))
|
|
}
|
|
|
|
if in.Account != nil {
|
|
out.From = &EmailPreviewFrom{Name: strings.TrimSpace(in.Account.Name), Email: in.Account.Email}
|
|
}
|
|
if in.Campaign != nil && s.attachmentRepo != nil {
|
|
atts, err := s.attachmentRepo.ListForStep(ctx, in.Campaign.ID, in.SequenceID)
|
|
if err != nil {
|
|
log.Warn().Err(err).Str("campaign_id", in.Campaign.ID.String()).Msg("preview: load campaign attachments failed")
|
|
}
|
|
for _, a := range atts {
|
|
out.Attachments = append(out.Attachments, EmailPreviewAttachment{ID: a.ID, Filename: a.Filename, Size: a.Size, MimeType: a.MimeType})
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// dropBlankHTMLPart removes an HTML alternative that would render nothing.
|
|
//
|
|
// An empty HTML part is worse than no HTML part: every modern client prefers
|
|
// text/html, so the recipient reads a blank message while the real copy sits
|
|
// unread in the text alternative. It catches whatever produced the row, which
|
|
// is the composer's <div></div> placeholder on an API-created step, but also a
|
|
// variant whose HTML-only spintax resolved away.
|
|
//
|
|
// With no plain part either there is nothing to fall back to, so the body is
|
|
// left alone; a campaign in that state is refused at start instead.
|
|
func dropBlankHTMLPart(bodyHTML, bodyPlain string) string {
|
|
if bodyHTML == "" || strings.TrimSpace(bodyPlain) == "" {
|
|
return bodyHTML
|
|
}
|
|
if mailhtml.HasContent(bodyHTML) {
|
|
return bodyHTML
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// finishBody applies what the send path adds after rendering, in its order:
|
|
// derive the plain part, drop HTML for a plain-text campaign, turn a
|
|
// hand-placed unsubscribe link into an anchor, add the mailbox signature,
|
|
// then the opt-out footer (nil settings skip it). Shared by the preview and
|
|
// the test send so both show what a recipient gets.
|
|
func finishBody(bodyHTML, bodyPlain string, textOnly bool, account *models.Email, optOut *models.UnsubscribeSettings, unsubURL string) (string, string) {
|
|
if bodyPlain == "" && bodyHTML != "" {
|
|
bodyPlain = ExtractPlainTextFromHTML(bodyHTML)
|
|
}
|
|
if textOnly {
|
|
bodyHTML = ""
|
|
}
|
|
// Shared with the send path, so the preview and the test send show the
|
|
// same message a recipient gets.
|
|
bodyHTML = dropBlankHTMLPart(bodyHTML, bodyPlain)
|
|
// After the plain part is derived, so plain text keeps the URL it needs.
|
|
linkText := ""
|
|
if optOut != nil {
|
|
linkText = optOut.LinkText
|
|
}
|
|
bodyHTML = linkifyUnsubscribeURL(bodyHTML, unsubURL, linkText)
|
|
if account != nil && account.SignatureSync {
|
|
if bodyHTML != "" {
|
|
bodyHTML = AddSignature(bodyHTML, account.SignatureHTML, true)
|
|
}
|
|
if bodyPlain != "" {
|
|
bodyPlain = AddSignature(bodyPlain, account.SignaturePlain, false)
|
|
}
|
|
}
|
|
if optOut != nil {
|
|
bodyHTML, bodyPlain = appendOptOut(bodyHTML, bodyPlain, *optOut, unsubURL)
|
|
}
|
|
// Last, as in the send path, so the preview shows the markup that ships
|
|
// rather than the markup that was written.
|
|
bodyHTML = mailhtml.InlineCSS(bodyHTML)
|
|
return bodyHTML, bodyPlain
|
|
}
|
|
|
|
// campaignAttachmentRefs lists the files one step's send carries, as the refs
|
|
// the worker resolves from object storage: the campaign-wide files plus the
|
|
// ones scoped to that step (uuid.Nil = campaign-wide only). A load failure
|
|
// sends without them rather than failing the send, and is logged.
|
|
func (s *tasksService) campaignAttachmentRefs(ctx context.Context, campaignID, sequenceID uuid.UUID) []models.AttachmentRef {
|
|
if s.attachmentRepo == nil {
|
|
return nil
|
|
}
|
|
atts, err := s.attachmentRepo.ListForStep(ctx, campaignID, sequenceID)
|
|
if err != nil {
|
|
log.Warn().Err(err).Str("campaign_id", campaignID.String()).Msg("Failed to load campaign attachments")
|
|
return nil
|
|
}
|
|
refs := make([]models.AttachmentRef, 0, len(atts))
|
|
for _, a := range atts {
|
|
refs = append(refs, models.AttachmentRef{S3Key: a.S3Key, Filename: a.Filename, MimeType: a.MimeType})
|
|
}
|
|
return refs
|
|
}
|