mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-15 16:02:45 +00:00
463 lines
22 KiB
Go
463 lines
22 KiB
Go
package tasks
|
|
|
|
import (
|
|
"fmt"
|
|
"math/rand"
|
|
"regexp"
|
|
"strings"
|
|
"text/template"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/warmbly/warmbly/internal/app/unsublink"
|
|
"github.com/warmbly/warmbly/internal/config"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
"github.com/warmbly/warmbly/internal/pkg/generation"
|
|
"github.com/warmbly/warmbly/internal/pkg/mailhtml"
|
|
"github.com/warmbly/warmbly/internal/pkg/tmplfuncs"
|
|
"github.com/warmbly/warmbly/internal/pkg/warmpersona"
|
|
)
|
|
|
|
// Conversation represents a warmup conversation for AI generation
|
|
type Conversation struct {
|
|
ID uuid.UUID
|
|
Version string
|
|
Subject string
|
|
Theme string
|
|
Description string
|
|
Messages []string
|
|
}
|
|
|
|
// TemplateContext holds typed per-send values, separate from contact fields.
|
|
type TemplateContext struct {
|
|
Sender TemplateSender
|
|
UnsubscribeLink string
|
|
}
|
|
|
|
// templateAction matches a single {{ ... }} action (no nested braces).
|
|
var templateAction = regexp.MustCompile(`\{\{[^{}]*\}\}`)
|
|
|
|
// spacedFieldRefAt matches, anchored at the start of the slice, a dotted field
|
|
// reference whose key contains an internal space or dash (e.g. ".job title" or
|
|
// ".first-name"). Such a key is not a valid Go-template identifier, so it cannot
|
|
// be written as a `.Selector`. We rewrite it to the equivalent `(index . "key")`,
|
|
// which IS valid everywhere — standalone, inside {{if}}, and inside eq/and/... —
|
|
// giving custom fields with spaces full Go-template support.
|
|
var spacedFieldRefAt = regexp.MustCompile(`^\.[A-Za-z0-9_]+(?:[ \-]+[A-Za-z0-9_]+)+`)
|
|
|
|
// tmplCache caches parsed templates keyed by the raw template string. A stored
|
|
// nil is a "known-bad" sentinel: that body failed to compile, so future renders
|
|
// skip straight to the naive fallback instead of re-parsing on every recipient.
|
|
// *template.Template is safe for concurrent Execute once parsed, so a single
|
|
// cached instance is reused across the whole send loop.
|
|
var tmplCache tmplfuncs.Cache
|
|
|
|
// Standard fields and the typed Sender namespace take precedence over custom fields.
|
|
func buildTemplateData(contact models.Contact, context TemplateContext) map[string]any {
|
|
data := make(map[string]any, len(contact.CustomFields)+7)
|
|
for k, v := range contact.CustomFields {
|
|
data[k] = v
|
|
}
|
|
data["FirstName"] = contact.FirstName
|
|
data["LastName"] = contact.LastName
|
|
data["Email"] = contact.Email
|
|
data["Company"] = contact.Company
|
|
data["Phone"] = contact.Phone
|
|
data["Sender"] = context.Sender
|
|
data[UnsubscribeLinkVar] = context.UnsubscribeLink
|
|
return data
|
|
}
|
|
|
|
// rewriteSpacedFieldRefs rewrites a dotted custom-field reference whose key has
|
|
// a space or dash (`.job title`, `.first-name`) into `(index . "key")` inside
|
|
// each {{ }} action, so those fields work EVERYWHERE — standalone, inside
|
|
// {{if}}, and inside eq/and/printf/... — not just as a literal substitution.
|
|
// Plain identifier selectors ({{.FirstName}}) and any text outside an action are
|
|
// left untouched, and quoted string literals inside an action are skipped so a
|
|
// value like ".NET" is never mangled. No data is needed: the rewrite is purely
|
|
// syntactic, so it works identically at render time and at validation time.
|
|
func rewriteSpacedFieldRefs(tmpl string) string {
|
|
if !strings.Contains(tmpl, "{{") {
|
|
return tmpl
|
|
}
|
|
return templateAction.ReplaceAllStringFunc(tmpl, rewriteSpacedInAction)
|
|
}
|
|
|
|
func rewriteSpacedInAction(action string) string {
|
|
var b strings.Builder
|
|
b.Grow(len(action))
|
|
var quote byte // 0 = not in a string literal; '"' or '`' otherwise
|
|
for i := 0; i < len(action); {
|
|
c := action[i]
|
|
switch {
|
|
case quote != 0:
|
|
b.WriteByte(c)
|
|
if c == '\\' && quote == '"' && i+1 < len(action) {
|
|
b.WriteByte(action[i+1]) // keep an escaped char verbatim
|
|
i += 2
|
|
continue
|
|
}
|
|
if c == quote {
|
|
quote = 0
|
|
}
|
|
i++
|
|
case c == '"' || c == '`':
|
|
quote = c
|
|
b.WriteByte(c)
|
|
i++
|
|
case c == '.':
|
|
// A nested struct selector must not consume the following function argument.
|
|
fieldStart := i == 0 || strings.ContainsRune("{( \t\r\n", rune(action[i-1]))
|
|
if m := spacedFieldRefAt.FindString(action[i:]); fieldStart && m != "" {
|
|
b.WriteString(`(index . "` + m[1:] + `")`)
|
|
i += len(m)
|
|
continue
|
|
}
|
|
b.WriteByte(c)
|
|
i++
|
|
default:
|
|
b.WriteByte(c)
|
|
i++
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// compiledTemplate returns a parsed, cached template for tmpl, or nil if the
|
|
// body is known-bad (caller falls back to standalone actions). missingkey=zero
|
|
// makes absent map keys test false in {{if .X}}. text/template
|
|
// (not html/template) performs no escaping, so the author's HTML body is emitted
|
|
// verbatim.
|
|
func compiledTemplate(tmpl string) *template.Template {
|
|
if t, ok := tmplCache.Load(tmpl); ok {
|
|
return t // may be nil (known-bad)
|
|
}
|
|
t, err := tmplfuncs.Compile("body", tmpl)
|
|
if err != nil {
|
|
tmplCache.Store(tmpl, nil)
|
|
return nil
|
|
}
|
|
tmplCache.Store(tmpl, t)
|
|
return t
|
|
}
|
|
|
|
// TemplateError returns a parse error when a template's control syntax is
|
|
// malformed (e.g. an {{if}} with no {{end}}, or a bad {{eq}}), or nil when it is
|
|
// valid. Spaced/dashed custom-field references are rewritten to their `index`
|
|
// form first (exactly as the renderer does), so a valid template that uses them
|
|
// inside {{if}} does not false-fail validation. Used to block starting a
|
|
// campaign with a template that would degrade to literal {{if}} text on send.
|
|
func TemplateError(tmpl string) error {
|
|
if tmpl == "" {
|
|
return nil
|
|
}
|
|
_, err := tmplfuncs.Compile("validate", rewriteSpacedFieldRefs(tmpl))
|
|
return err
|
|
}
|
|
|
|
// RenderTemplate renders a sequence template against a contact, supporting Go
|
|
// text/template conditionals ({{if}}/{{else}}/{{eq}}), standard variables, and
|
|
// custom fields. It NEVER hard-fails: any parse or execution error falls back to
|
|
// standalone actions so a send always produces a body. Spintax is
|
|
// intentionally left untouched here (single-brace {a|b} survives the template
|
|
// pass) and expanded later in the pipeline where applicable.
|
|
func RenderTemplate(tmpl string, contact models.Contact) string {
|
|
return RenderTemplateWith(tmpl, contact, TemplateContext{})
|
|
}
|
|
|
|
// RenderTemplateWith uses Go's native struct access for the sending mailbox.
|
|
func RenderTemplateWith(tmpl string, contact models.Contact, context TemplateContext) string {
|
|
if tmpl == "" {
|
|
return tmpl
|
|
}
|
|
|
|
data := buildTemplateData(contact, context)
|
|
prepared := rewriteSpacedFieldRefs(tmpl)
|
|
|
|
t := compiledTemplate(prepared)
|
|
if t == nil {
|
|
return fallbackRenderTemplate(tmpl, data)
|
|
}
|
|
|
|
out, err := tmplfuncs.Execute(t, data)
|
|
if err != nil {
|
|
return fallbackRenderTemplate(tmpl, data)
|
|
}
|
|
return strings.ReplaceAll(out, "<no value>", "")
|
|
}
|
|
|
|
// Recover standalone actions with the same engine, leaving broken control syntax literal.
|
|
func fallbackRenderTemplate(tmpl string, data map[string]any) string {
|
|
return templateAction.ReplaceAllStringFunc(tmpl, func(action string) string {
|
|
t := compiledTemplate(rewriteSpacedFieldRefs(action))
|
|
if t == nil {
|
|
return action
|
|
}
|
|
out, err := tmplfuncs.Execute(t, data)
|
|
if err != nil {
|
|
return action
|
|
}
|
|
return strings.ReplaceAll(out, "<no value>", "")
|
|
})
|
|
}
|
|
|
|
// TemplatePreview is the result of rendering a campaign template against one
|
|
// contact, plus any problems found — used by the composer's live preview and
|
|
// inline validation so a broken {{if}} or a token that won't resolve is caught
|
|
// before launch instead of shipping literally.
|
|
type TemplatePreview struct {
|
|
Subject string `json:"subject"`
|
|
BodyHTML string `json:"body_html"`
|
|
BodyPlain string `json:"body_plain"`
|
|
Errors []string `json:"errors,omitempty"` // template parse errors (these block sending)
|
|
Unresolved []string `json:"unresolved,omitempty"` // literal {{…}} tokens left after render
|
|
// HTMLFindings is what mail clients will do to this body: what they strip,
|
|
// what CSS they ignore, and whether Gmail will clip it. Advisory only.
|
|
HTMLFindings []mailhtml.Finding `json:"html_findings,omitempty"`
|
|
}
|
|
|
|
// PreviewUnsubscribeLink stands in for the per-recipient link in previews.
|
|
// The same width as a real one, so a plain-text preview (where the address is
|
|
// printed in full) reads as wide as the send will.
|
|
const PreviewUnsubscribeLink = "https://example.com" + unsublink.Path + unsublink.ExampleTicket
|
|
|
|
// unresolvedToken matches a {{…}} token still present after rendering (i.e. one
|
|
// that failed to parse and fell through to literal substitution).
|
|
var unresolvedToken = regexp.MustCompile(`\{\{[^{}]*\}\}`)
|
|
|
|
// PreviewTemplates renders subject/html/plain against contact EXACTLY as the
|
|
// send path does (template render + spintax), and reports parse errors plus any
|
|
// tokens that did not resolve.
|
|
func PreviewTemplates(subject, bodyHTML, bodyPlain string, contact models.Contact) TemplatePreview {
|
|
return previewTemplatesWith(subject, bodyHTML, bodyPlain, contact, TemplateContext{UnsubscribeLink: PreviewUnsubscribeLink})
|
|
}
|
|
|
|
// previewTemplatesWith renders with the same sender context as a real send.
|
|
func previewTemplatesWith(subject, bodyHTML, bodyPlain string, contact models.Contact, context TemplateContext) TemplatePreview {
|
|
p := TemplatePreview{
|
|
Subject: expandSpintax(RenderTemplateWith(subject, contact, context)),
|
|
BodyHTML: expandSpintax(RenderTemplateWith(bodyHTML, contact, context)),
|
|
BodyPlain: expandSpintax(RenderTemplateWith(bodyPlain, contact, context)),
|
|
}
|
|
for _, f := range []struct{ name, raw string }{{"subject", subject}, {"body", bodyHTML}, {"plain text", bodyPlain}} {
|
|
if err := TemplateError(f.raw); err != nil {
|
|
p.Errors = append(p.Errors, f.name+": "+err.Error())
|
|
}
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, out := range []string{p.Subject, p.BodyHTML, p.BodyPlain} {
|
|
for _, tok := range unresolvedToken.FindAllString(out, -1) {
|
|
if !seen[tok] {
|
|
seen[tok] = true
|
|
p.Unresolved = append(p.Unresolved, tok)
|
|
}
|
|
}
|
|
}
|
|
return p
|
|
}
|
|
|
|
// AddSignature places the mailbox signature under the body. HTML gets its own
|
|
// block with a top margin, not <br><br>: the breaks stacked against the body's
|
|
// own trailing margin and showed as blank lines in Apple Mail and Outlook.
|
|
func AddSignature(body string, signature string, isHTML bool) string {
|
|
if signature == "" {
|
|
return body
|
|
}
|
|
|
|
if !isHTML {
|
|
return body + "\n\n" + signature
|
|
}
|
|
|
|
block := `<div style="margin-top:16px">` + signature + `</div>`
|
|
// Inside the container the email was laid out in, as for the opt-out
|
|
// footer: after it, a signature lands against the left edge of the window
|
|
// instead of under the copy it signs off (issue #462).
|
|
return mailhtml.AppendToContent(body, block)
|
|
}
|
|
|
|
// AppendForwarded puts the message a forward carries under the note and
|
|
// signature, the order every mail client writes a forward in.
|
|
func AppendForwarded(bodyHTML, bodyPlain, forwardedHTML, forwardedPlain string) (string, string) {
|
|
if forwardedHTML != "" {
|
|
if strings.TrimSpace(bodyHTML) == "" {
|
|
bodyHTML = forwardedHTML
|
|
} else {
|
|
bodyHTML = mailhtml.AppendToContent(bodyHTML, "<br>"+forwardedHTML)
|
|
}
|
|
}
|
|
if forwardedPlain != "" {
|
|
if note := strings.TrimRight(bodyPlain, " \t\r\n"); note == "" {
|
|
bodyPlain = forwardedPlain
|
|
} else {
|
|
bodyPlain = note + "\n\n" + forwardedPlain
|
|
}
|
|
}
|
|
return bodyHTML, bodyPlain
|
|
}
|
|
|
|
// AddOpenTrackingPixel adds an invisible tracking pixel to HTML email.
|
|
// The pixel URL points to the tracking service endpoint: /t/o/{taskID}.png.
|
|
//
|
|
// An empty host means this install has no tracking host at all, and the pixel
|
|
// is left out. It used to fall back to a hardcoded warmbly.com name, so a
|
|
// self-hosted deployment mailed a pixel pointing at someone else's service:
|
|
// nothing was ever recorded, and the recipient's open was reported to a third
|
|
// party instead.
|
|
func AddOpenTrackingPixel(htmlBody string, taskID uuid.UUID, trackingDomain string) string {
|
|
pixelURL := config.TrackingURL(trackingDomain, fmt.Sprintf("/t/o/%s.png", taskID.String()))
|
|
if pixelURL == "" {
|
|
return htmlBody
|
|
}
|
|
pixel := fmt.Sprintf(`<img src="%s" width="1" height="1" style="display:none;" alt="" />`, pixelURL)
|
|
return mailhtml.InsertBeforeBodyEnd(htmlBody, pixel)
|
|
}
|
|
|
|
// personaPick chooses from a mailbox's preferred subset of phrasing options so
|
|
// each mailbox keeps a consistent "voice" while still varying message to
|
|
// message. Falls back gracefully for tiny option sets.
|
|
func personaPick(p warmpersona.Persona, axis string, opts []string) string {
|
|
if len(opts) == 0 {
|
|
return ""
|
|
}
|
|
k := 3
|
|
if k > len(opts) {
|
|
k = len(opts)
|
|
}
|
|
subset := p.Subset(axis, len(opts), k)
|
|
if len(subset) == 0 {
|
|
return opts[rand.Intn(len(opts))]
|
|
}
|
|
return opts[subset[rand.Intn(len(subset))]]
|
|
}
|
|
|
|
// GenerateConversationReplyEmail renders one ordered reply turn. Turn 1 maps
|
|
// to Messages[0]; exhausted threads return false instead of repeating a line.
|
|
func GenerateConversationReplyEmail(conversation Conversation, account models.Email, turn int) (string, bool) {
|
|
if turn < 1 || turn > len(conversation.Messages) {
|
|
return "", false
|
|
}
|
|
body := strings.TrimSpace(conversation.Messages[turn-1])
|
|
if body == "" || strings.ContainsAny(body, "<>{}\x00\r") {
|
|
return "", false
|
|
}
|
|
|
|
signature := account.Name
|
|
if signature == "" {
|
|
signature = account.Email
|
|
}
|
|
|
|
rendered, err := generation.RenderCanonicalTurn(body, signature)
|
|
return rendered, err == nil
|
|
}
|
|
|
|
// GenerateConversationOpeningEmail renders an AI opening without consuming a
|
|
// pre-generated reply turn.
|
|
func GenerateConversationOpeningEmail(conversation Conversation, account models.Email) string {
|
|
return generateConversationOpeningEmail(conversation, account)
|
|
}
|
|
|
|
// GenerateConversationEmail renders an opening or the first ordered reply.
|
|
func GenerateConversationEmail(conversation Conversation, account models.Email, isReply bool) string {
|
|
if isReply {
|
|
body, _ := GenerateConversationReplyEmail(conversation, account, 1)
|
|
return body
|
|
}
|
|
return generateConversationOpeningEmail(conversation, account)
|
|
}
|
|
|
|
// Openings never consume a future reply or append a random question.
|
|
func generateConversationOpeningEmail(conversation Conversation, account models.Email) string {
|
|
signature := account.Name
|
|
if signature == "" {
|
|
signature = account.Email
|
|
}
|
|
|
|
description := strings.TrimSpace(conversation.Description)
|
|
if description == "" || strings.ContainsAny(description, "<>{}\x00\r") {
|
|
return ""
|
|
}
|
|
rendered, _ := generation.RenderCanonicalTurn(description, signature)
|
|
return rendered
|
|
}
|
|
|
|
// VettedDiagnosticConversations uses a separate immutable source-ID namespace.
|
|
func VettedDiagnosticConversations() []Conversation {
|
|
return []Conversation{
|
|
{ID: uuid.NewSHA1(uuid.NameSpaceOID, []byte("warmbly:diagnostic-clock:v1")), Version: generation.DiagnosticScenarioVersion, Theme: "diagnostic-clock", Subject: "Simulated diagnostic: fictional clock", Description: "Simulated diagnostic. In this fictional example, the clock reads 14:00, not 15:00. Which time does the example use?", Messages: []string{"The fictional example uses 14:00, not 15:00.", "Agreed. The example is closed; no real meeting was scheduled."}},
|
|
{ID: uuid.NewSHA1(uuid.NameSpaceOID, []byte("warmbly:diagnostic-count:v1")), Version: generation.DiagnosticScenarioVersion, Theme: "diagnostic-count", Subject: "Simulated diagnostic: sample count", Description: "Simulated diagnostic. This hypothetical sample contains 3 blue cards and 2 green cards. How many cards are in the sample?", Messages: []string{"There are 5 cards in the hypothetical sample: 3 blue and 2 green.", "That matches the hypothetical sample. This scenario is complete; no physical cards were exchanged."}},
|
|
{
|
|
ID: uuid.NewSHA1(uuid.NameSpaceOID, []byte("warmbly:diagnostic-document:v1")), Version: generation.DiagnosticScenarioVersion, Theme: "diagnostic-document", Subject: "Simulated diagnostic: hypothetical draft review",
|
|
Description: "Simulated diagnostic. In this hypothetical draft, Section A lists assumptions and Section B lists unknowns. An erroneous summary says inbox placement passed, but the draft contains no measured delivery results. Which claim needs correction?",
|
|
Messages: []string{
|
|
"The hypothetical inbox-placement claim needs correction: neither assumptions nor unknowns establish a measured result. Should the summary instead distinguish Section A from Section B?",
|
|
"Yes. Section A lists assumptions; Section B lists unknowns. Neither section proves inbox placement. Can we close the fictional review with that distinction?",
|
|
"Agreed. The fictional summary distinguishes assumptions from unknowns and makes no placement claim. This review scenario is closed; no real document was reviewed or changed.",
|
|
},
|
|
},
|
|
{
|
|
ID: uuid.NewSHA1(uuid.NameSpaceOID, []byte("warmbly:diagnostic-summary:v1")), Version: generation.DiagnosticScenarioVersion, Theme: "diagnostic-summary", Subject: "Simulated diagnostic: summary correction",
|
|
Description: "Simulated diagnostic. A fictional checklist has 12 items: 9 marked complete and 3 marked unknown, with none marked failed. A draft incorrectly calls all 12 complete. What should the corrected summary say?",
|
|
Messages: []string{
|
|
"The fictional checklist has 9 complete items and 3 unknown items, not 12 complete items. Does an unknown item count as failed or passed?",
|
|
"No. Unknown items are neither failed nor passed; the checklist has none marked failed. Should the corrected summary keep the 9 complete and 3 unknown counts separate?",
|
|
"Yes. The corrected hypothetical summary keeps 9 complete and 3 unknown items separate, with none marked failed. The example is closed; no actual provider check was performed.",
|
|
},
|
|
},
|
|
{
|
|
ID: uuid.NewSHA1(uuid.NameSpaceOID, []byte("warmbly:diagnostic-timezone:v1")), Version: generation.DiagnosticScenarioVersion, Theme: "diagnostic-timezone", Subject: "Simulated diagnostic: fixed-offset date note",
|
|
Description: "Simulated diagnostic. On 2026-10-08 the fictional clock reads 09:00 UTC, or 11:00 at the fixed offset UTC+02:00. This is not a meeting invitation. Which local date and time belong in the hypothetical note?",
|
|
Messages: []string{
|
|
"The note should use 2026-10-08 at 11:00 with the fixed offset UTC+02:00, corresponding to 09:00 UTC. Should it keep the fixed offset rather than guess a regional timezone?",
|
|
"Yes. Keep 2026-10-08, 09:00 UTC and 11:00 at UTC+02:00 unchanged. The example specifies no regional timezone or meeting. Can we close without creating an appointment?",
|
|
"Agreed. The fictional date remains 2026-10-08, with 09:00 UTC equal to 11:00 at UTC+02:00. This example is closed; no appointment was created.",
|
|
},
|
|
},
|
|
{
|
|
ID: uuid.NewSHA1(uuid.NameSpaceOID, []byte("warmbly:diagnostic-plaintext:v1")), Version: generation.DiagnosticScenarioVersion, Theme: "diagnostic-plaintext", Subject: "Simulated diagnostic: plaintext label review",
|
|
Description: "Simulated diagnostic. This constructed plaintext example uses written labels, not color or linked instructions. Its hypothetical items are Status: unknown and Next step: review. Can the labels be understood without opening a link?",
|
|
Messages: []string{
|
|
"Yes. Status: unknown and Next step: review are written in the example, without a link or color requirement. Does that alone prove how an email client displays a delivered message?",
|
|
"No. This is a locally constructed example, not a delivered-client accessibility test. Should the summary preserve the unknown status and that testing limit?",
|
|
"Yes. The example retains Status: unknown and Next step: review. This label-review scenario is closed; no delivered-client accessibility result is claimed.",
|
|
},
|
|
},
|
|
{
|
|
ID: uuid.NewSHA1(uuid.NameSpaceOID, []byte("warmbly:diagnostic-conditional:v1")), Version: generation.DiagnosticScenarioVersion, Theme: "diagnostic-conditional", Subject: "Simulated diagnostic: conditional wording",
|
|
Description: "Simulated diagnostic. In a hypothetical plan, a draft may be updated only if a reviewer approves it. No approval exists, and no update is promised for 2026-10-09. What should the draft status say?",
|
|
Messages: []string{
|
|
"The hypothetical status is pending review: no approval exists and the update is conditional. Should the wording promise an update on 2026-10-09?",
|
|
"No. The approval condition has not been met, so no update is promised for 2026-10-09. Can we keep pending review instead of implying an approval?",
|
|
"Agreed. The hypothetical draft remains pending review, without approval or a promised update for 2026-10-09. This scenario is closed; no real draft was changed or commitment made.",
|
|
},
|
|
},
|
|
{
|
|
ID: uuid.NewSHA1(uuid.NameSpaceOID, []byte("warmbly:diagnostic-summary-hu:v1")), Version: generation.DiagnosticScenarioVersion, Theme: "diagnostic-summary-hu", Subject: "Simulated diagnostic: magyar összefoglaló",
|
|
Description: "Simulated diagnostic. Ebben a kitalált jegyzetben 4 ellenőrzésből 3 eredménye ismeretlen, 1 pedig nincs elvégezve. Egyik sem igazolt siker. Hogyan őrizzük meg ezt a különbséget az összefoglalóban?",
|
|
Messages: []string{
|
|
"Az összefoglalóban 3 ismeretlen eredményt és 1 el nem végzett ellenőrzést írjunk; ne állítsunk sikert. Jelenthet-e az ismeretlen eredmény igazolt sikert?",
|
|
"Nem. Az ismeretlen nem igazolt siker, az el nem végzett ellenőrzés pedig továbbra sincs elvégezve. Maradjon ez kitalált példa, ne szolgáltatói mérés?",
|
|
"Igen. A kitalált példa lezárult: 3 eredmény ismeretlen, 1 ellenőrzés nincs elvégezve. Valós szolgáltatói tesztet nem végeztünk.",
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func ResolveDiagnosticConversation(id uuid.UUID) (Conversation, bool) {
|
|
for _, c := range VettedDiagnosticConversations() {
|
|
if c.ID == id {
|
|
return c, true
|
|
}
|
|
}
|
|
return Conversation{}, false
|
|
}
|
|
|
|
// ExtractPlainTextFromHTML derives the text/plain alternative that ships
|
|
// beside an HTML body.
|
|
//
|
|
// It used to strip tags with a regex, which put a designed email's whole
|
|
// stylesheet at the top of the text part and ran every paragraph into one
|
|
// line (issue #393). mailhtml renders the document instead, so blocks, lists,
|
|
// table rows and link destinations survive.
|
|
func ExtractPlainTextFromHTML(body string) string {
|
|
return mailhtml.ToPlainText(body)
|
|
}
|