mirror of
https://github.com/warmbly/warmbly.git
synced 2026-08-23 16:00:30 +00:00
94cf21d95e
* feat: add internal/pkg/mailhtml, a mail-oriented HTML sanitizer and text flattener, because rendering a received message body means rendering the sender's markup: Sanitize builds on bluemonday's UGC policy but keeps what real email is made of (table layout attributes, inline CSS through the property-allowlisted style sanitizer, legacy font/center, data: and https: images) while dropping script, iframe, object and the text content of style/head blocks so a marketing email's stylesheet cannot render as body copy, forcing target=_blank plus nofollow/noreferrer on links and allowing only http, https, mailto and tel; ToText flattens the same input for previews, turning block boundaries into newlines and decoding entities back to the characters they stand for so an already-escaped body does not surface as literal &; LooksLikeHTML reports whether a stored body is actually markup, which is how a body recorded as HTML by an older sync but containing no tag at all can be recognised as the plain text it really is * feat: add internal/pkg/mailhdr for RFC 5322 header values, since headers are ASCII on the wire and every transport was writing raw UTF-8 into Subject and display names: Subject and AddressList RFC 2047-encode non-ASCII (a no-op on plain ASCII, and a bare address stays bare rather than being wrapped in angle brackets), DecodeWords reverses encoded-words with a charset hook wired to go-message so legacy encodings Go does not handle natively still decode, and Bare/BareList strip a display name down to the routable address for SMTP envelope commands where 'Ana <a@b.com>' in RCPT TO is a syntax error, promoting go-message from an indirect to a direct dependency * feat: encode outbound Subject and address headers on all three transports, so a subject or sender name containing an accent, a currency sign or an emoji reaches the recipient as the characters the user typed instead of mojibake: SMTP and Graph were writing the raw string into Subject (only the Gmail transport encoded it) and Graph built its From by fmt.Sprintf rather than mail.Address, so a non-ASCII display name went out unencoded there too, and all three joined To/Cc/Bcc entries verbatim so an encoded display name never appeared even when the caller supplied one; additionally the SMTP envelope now takes bare addresses through mailhdr.BareList, because an API caller may pass 'Name <addr>' (the compose handler has a bareAddress helper precisely because that arrives) and passing that to RCPT TO gets the recipient rejected by the server * feat: rewrite the IMAP body reader, which was the reason received mail from SMTP/IMAP mailboxes came back corrupted: it built one FetchItemBodySection with a hardcoded Part []int{1} and a comment saying it would adjust when recursing, which it never did, so on a multipart/alternative the text/plain bytes were fetched twice and the second copy was stored as the HTML body (plain text rendered as markup loses every line break, shows & as an entity and swallows anything inside angle brackets), and decodeIfNeeded never reversed Content-Transfer-Encoding at all, leaving quoted-printable bodies full of =E2=80=99 runs and = soft breaks and base64 bodies unreadable, while its charset detection parsed params off a media-type string that never carried any and its mail.ReadMessage call could silently eat leading body lines as headers; the reader now walks the body structure for real part paths, fetches every text leaf in a single FETCH with a server-side Partial size cap, decodes quoted-printable and base64 (tolerating a tail cut mid-quantum by the cap) then converts the part's charset to UTF-8 with go-message, skips attachment-disposition parts so a .txt attachment cannot stand in for the body, takes one part per type inside a multipart/alternative but treats sibling inline parts in mixed/related as additive, and is bounded at five text parts per message; the stored body cap also goes from 200 KB to 512 KB because 200 KB cuts real HTML newsletters mid-document * feat: decode Gmail's raw headers and entity-escaped snippets, because the Gmail API hands header values back exactly as they arrived on the wire, so a message from a sender whose subject or display name was RFC 2047-encoded showed in the dashboard as =?utf-8?q?caf=C3=A9?= rather than as the text it stands for, and the API's own snippet field is HTML-escaped, so a preview containing an apostrophe surfaced as ' in the conversation list and, until the thread reader stopped rendering snippets as message bodies, inside the message itself; getSingleHeader now runs values through mailhdr.DecodeWords (a no-op unless the value actually contains an encoded-word, so Message-ID and the warmup token header are untouched), the comma-split fallback in getAddressList does the same for display names net/mail could not parse, and the snippet is unescaped once on the way in * feat: fix the conversation-list snippet, which collapsed whitespace before splitting on newlines so the quoted-line and signature filters below it could never match a thing, stripped HTML with bluemonday's strict policy and then showed the escaped output verbatim so an ampersand in an HTML-only message read as & and a marketing email's stylesheet text rendered as body copy, and cut at 100 bytes with text[:100] so a multi-byte character or emoji at the boundary became a replacement glyph; it now flattens HTML through mailhtml.ToText (entities decoded, style and script content dropped) including when a sender puts markup in their text/plain part, filters quoted history and everything past the RFC 3676 signature delimiter while the text still has lines, collapses whitespace afterwards, and truncates on a rune boundary at 200 characters * feat: make GET /unibox/:id serve a display-safe body and stop it failing outright, sanitizing body_html through mailhtml before it leaves the API so every consumer gets markup that cannot execute rather than each call site having to defend itself, degrading a body blob that cannot be read to the message's preview text with a new body_truncated flag instead of returning 500 (which made a message with a missing blob unopenable, and hit every seed, sandbox and dev-history fixture row since only the '<seed-' prefix was recognised while the sandbox uses '<sbx-' and dev history '<dev-'), and treating a stored HTML body that contains no tag at all as the plain text it really is, because mail synced before the IMAP reader addressed parts individually recorded the plain part under both bodies and serving that as HTML is exactly what collapsed a ten-line message onto one line * feat: escape composer text before turning it into the HTML part of an outgoing email, replacing body_html: trimmedBody.replace(/\n/g, '<br />') in both the compose window and the reply composer with a shared plainToHtml that escapes the five markup characters first, so an email containing 'Terms & conditions' no longer ships a broken entity and one containing anything in angle brackets ('<see attached>', 'a < b', a pasted tag) no longer has the rest of the paragraph swallowed by the recipient's mail client as an unclosed tag, while runs of spaces survive as non-breaking spaces and bare URLs become links without eating the sentence punctuation after them; the same unescaped plain-to-HTML pattern in the campaign step editor's applyTemplate now goes through promptToHtml, which escapes as it paragraph-wraps * feat: render the real message body in the unibox thread reader instead of the list preview, which is the whole of the reported bug: ThreadView mapped each thread row to a UniboxEmail whose body was '<p>' + escapeHtml(m.snippet) + '</p>' and MessageBubble rendered that as the message, but a snippet is a preview capped at 100 characters with every run of whitespace collapsed to one space, so a ten-line email displayed as roughly two lines on a single continuous line, and Gmail's already-escaped snippet was escaped a second time so an apostrophe read as '; each expanded message now loads its own body from GET /unibox/:id (the newest message and anything unread open on mount, older messages collapse to their preview line so a long thread does not fetch every body at once) and renders it in a sandboxed iframe carrying no allow-scripts, which keeps a sender's stylesheet from restyling the dashboard and means nothing in the message can run even though the API already sanitized it, sizing itself from the inner document as images load, with the preview kept as the fallback when a body cannot be fetched and a notice when only a preview is stored * feat: document how a message body is read and returned, adding a 'Reading a message' section to the unibox guide covering the expand-on-open behaviour, that formatting and special characters are preserved as sent, that the conversation list preview is a summary and not the message, and that HTML mail renders in an isolated frame with links opening in a new tab, plus a paragraph in the API endpoint reference stating that GET /unibox and GET /unibox/thread return previews carrying snippet while GET /unibox/:id returns body_plain and a sanitized body_html, and what body_truncated means * feat: add email_accounts.save_to_sent, the per-mailbox switch for filing a copy of outbound mail in the Sent folder, defaulting on because plain SMTP submission leaves nothing behind in the sender's account while Gmail and Outlook file their own copy through their APIs, making it a per-mailbox choice rather than a global one since a submission server that files the copy itself (Gmail's SMTP, Fastmail, Zoho) would otherwise end up with two of everything, which is exactly why every desktop mail client ships the same switch, and wiring the column through the Email model, the mailbox read paths and UpdateEmail so it is readable and writable from the dashboard and the API * feat: teach the IMAP client to APPEND a sent message and the SMTP client to hand back the exact bytes it submitted, the two transport pieces the Sent-folder copy needs: AppendToSent resolves the folder from the RFC 6154 \\Sent special-use attribute first (requesting it only when the server advertises SPECIAL-USE) and falls back to matching the known names against both the full mailbox name and its leaf, since servers namespace as INBOX.Sent and localize the label, caches the result for the life of the connection, files the message flagged \\Seen and dated when it was sent, and returns a sentinel rather than an error when the account has no Sent folder at all; APPEND addresses its mailbox by argument and never touches the selected mailbox, so unlike the warmup MOVE/STORE actions it is safe to run while the sync loop is mid-fetch on the same connection * feat: file a copy of every SMTP send in the mailbox's Sent folder, closing the gap where a message sent from Warmbly through an SMTP/IMAP mailbox existed only in the recipient's inbox: nothing appeared in the customer's own mail client, and nothing appeared in the unibox either, whose thread reader can only show messages the sync found in a folder, so a user who sent from the dashboard and then went looking for what they sent found no record of it at all; the worker now APPENDs the exact bytes the SMTP client submitted after a successful send, best effort so a failed append never turns a delivered message into a failed task, skipping warmup traffic because filing dozens of machine-generated messages a day would bury the customer's real sent mail, and skipping Gmail and Graph mailboxes entirely since their APIs file their own copy; the per-mailbox setting rides along on the add-email worker payload as a pointer so an older control plane that does not send the field is read as unset and takes the default rather than as an explicit no * feat: expose the Sent folder copy as a mailbox setting in the dashboard, adding a 'Keep a copy of sent mail' toggle to the Settings tab of the mailbox drawer that only renders for SMTP/IMAP mailboxes (Gmail and Outlook file their own copy, so the control would be a lie there), tracked by the drawer's save bar alongside the other editable fields, and worded so the one case where it should be turned off is obvious: a provider that already saves its own copy, where leaving it on means seeing every sent message twice * feat: document the Sent folder copy in the mailboxes guide and the API reference, explaining why the toggle exists at all (SMTP submission leaves nothing in the sender's own account, so without it a sent message shows in neither the customer's mail client nor the unibox thread), when to turn it off (a provider such as Gmail, Fastmail or Zoho that already files its own copy of anything submitted over SMTP, where leaving it on doubles every message), that OAuth Gmail and Outlook mailboxes never show the control because their APIs file the copy themselves, that warmup traffic is deliberately excluded, and that PATCH /emails/:id takes save_to_sent * feat: add unibox_emails.body_text and its search index, because unibox search ran against search_tsv, a generated column built from subject and snippet, and a snippet is a truncated one-line preview, so searching for a phrase that appears in the third paragraph of an email returned nothing at all and read as broken search rather than as search that only covers the first line; message bodies stay in object storage where they belong, and what lands in Postgres is a bounded 16 KB plain-text rendering carried on the new-email worker event, indexed with a GIN expression index rather than a second stored generated column since adding one of those rewrites the whole table while this builds against a column that is empty on every existing row * feat: index what a message actually says, adding mailhtml.SearchText (HTML flattened, entities decoded, whitespace collapsed, quoted history deliberately kept because a phrase someone quoted back at you should still find the conversation, truncated on a rune boundary) and computing it on all three sync paths so IMAP, Gmail and Graph mail all arrive with searchable text, writing it on insert, and widening the unibox search filter to match either the existing subject-and-preview vector or the body expression, written exactly as the new index declares it so the index is actually used * feat: backfill the searchable text of messages that were synced before bodies were indexed, so search covers the archive a customer already has instead of only mail that arrives from now on, which would have made the feature useless on day one for exactly the people who need it; the sweep pages through unibox_emails by id, reads each body from object storage under the mailbox owner's key, renders it with the same helper the sync path uses and writes it back, at 100 rows per 30 seconds because nothing waits on it, and returns for good once a pass finds nothing left to visit, with rows whose stored body really is empty simply revisited after the next restart rather than needing a tried-and-failed marker in the schema * feat: document that unibox search now covers message text and not just subjects and previews, in the search paragraph of the unibox guide where the old wording only promised that search stays inside the current scope * feat: add generation.RenderThread, the shared way to put a conversation in front of a model, because every AI surface was grounding on preview snippets and a draft written from the first hundred characters of each email answers the greeting rather than the question; it strips quoted history and signatures (the earlier messages are already in the prompt on their own, so quoting them again spends the budget twice, though a reply written underneath the quote is kept rather than thrown away when there is nothing meaningful above the attribution line), spends a bounded character budget newest-message-first since the message being replied to matters most, degrades older messages to their preview line instead of dropping them once the budget runs low, and renders oldest-first so the transcript reads in order * feat: add grounding reads to the unibox service and repository, returning message text (the stored body, falling back to the preview for mail synced before bodies were indexed) for a thread or for all correspondence with one address, kept deliberately separate from the preview queries and given their own result type so a 16 KB body can never leak into a list response by accident, capped at twenty messages whatever a caller asks for, and paired with a RenderGrounding helper so every AI surface formats a conversation the same way instead of each one rolling its own transcript loop * feat: ground every AI writing surface in what the messages actually say, switching the unibox reply draft, the compose draft's correspondence history, the inbox agent's thread history and the assistant's read-thread tool from preview snippets to real message text through the new grounding reads, which is what makes a drafted reply answer the question that was asked rather than the first sentence of the email; the inbox agent's triviality gate also reads the reply's full text now, since a preview line cannot tell a one-word ack apart from a long message that happens to open with one, and the assistant tool returns a bounded body per message with quoted history stripped instead of a snippet field * feat: say in the docs that AI drafting reads the messages and not their previews, in both the unibox reply-draft section (adding that quoted history is stripped and the newest messages get the most room, so a draft answers what was asked rather than the opening sentence) and the inbox agent's grounding section, where 'the full thread so far' was true of the message list but not of how much of each message the model actually saw * feat: renumber the two new migrations to 000087 and 000088 after rebasing onto main, which landed its own 000085 (org data transfer) and 000086 (email sync state) in the meantime, so the sequence has no duplicate versions * feat: add the two new API fields to the OpenAPI spec that landed on main while this branch was open, documenting save_to_sent on the Mailbox and MailboxUpdate schemas and body_truncated on UniboxEmail, and saying on body_html that what the API returns is already sanitized so a client can render it directly
230 lines
7.6 KiB
Go
230 lines
7.6 KiB
Go
// AI reply draft for the unibox composer. Assembles the thread history, the
|
|
// counterpart contact (with custom fields and campaign membership), and the org
|
|
// voice profile into a context-grounded prompt, charges 2 credits, and returns
|
|
// a draft the human reviews and sends. It never sends anything itself.
|
|
package handler
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/warmbly/warmbly/internal/api/middleware"
|
|
"github.com/warmbly/warmbly/internal/app/credits"
|
|
"github.com/warmbly/warmbly/internal/app/unibox"
|
|
"github.com/warmbly/warmbly/internal/errx"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
"github.com/warmbly/warmbly/internal/pkg/generation"
|
|
)
|
|
|
|
func isInsufficientCredits(err error) bool { return errors.Is(err, credits.ErrInsufficientCredits) }
|
|
func isCapExceeded(err error) bool { return errors.Is(err, credits.ErrCapExceeded) }
|
|
|
|
// DraftReply — POST /unibox/reply/draft
|
|
func (h *Handler) DraftReply(c *gin.Context) {
|
|
orgID := middleware.GetOrganizationID(c)
|
|
if orgID == nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
|
|
return
|
|
}
|
|
userID, err := middleware.GetUserUUID(c)
|
|
if err != nil {
|
|
errx.JSON(c, errx.New(errx.Unauthorized, "invalid user"))
|
|
return
|
|
}
|
|
if h.AIProvider == nil {
|
|
errx.JSON(c, errx.New(errx.ServiceUnavailable, "the AI assistant is not configured"))
|
|
return
|
|
}
|
|
|
|
// Unibox entitlement + AI credits gate the feature.
|
|
if allowed, xerr := h.FeatureGateService.CanUseUnibox(c.Request.Context(), *orgID); xerr != nil {
|
|
errx.JSON(c, xerr)
|
|
return
|
|
} else if !allowed {
|
|
errx.JSON(c, errx.New(errx.Forbidden, "The unified inbox requires an active trial or paid subscription."))
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
ThreadID string `json:"thread_id" binding:"required"`
|
|
Instruction string `json:"instruction"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "invalid request body"))
|
|
return
|
|
}
|
|
|
|
// Assemble thread context from the messages themselves, not their preview
|
|
// lines: a draft grounded on the first hundred characters of each email
|
|
// answers the greeting and misses what was actually asked.
|
|
msgs, xerr := h.UniboxService.ThreadGrounding(c.Request.Context(), *orgID, req.ThreadID, unibox.GroundingLimitMax)
|
|
if xerr != nil {
|
|
errx.JSON(c, xerr)
|
|
return
|
|
}
|
|
if len(msgs) == 0 {
|
|
errx.JSON(c, errx.New(errx.NotFound, "thread not found"))
|
|
return
|
|
}
|
|
history, counterpart := buildThreadContext(msgs)
|
|
|
|
// Look up the counterpart contact for grounding (best-effort).
|
|
contactCtx := h.contactContext(c, userID, *orgID, counterpart)
|
|
|
|
// Model tier + voice.
|
|
paid, _ := h.FeatureGateService.IsPaidOrganization(c.Request.Context(), *orgID)
|
|
model := h.AIProvider.ModelForTier(paid)
|
|
voice := h.orgVoice(c.Request.Context(), *orgID, "")
|
|
|
|
// Charge 2 credits up front (idempotent on the client's key); refund on
|
|
// provider failure. A free/local model (AI_FREE) runs un-metered.
|
|
idemKey := strings.TrimSpace(c.GetHeader("Idempotency-Key"))
|
|
local := h.AIProvider != nil && h.AIProvider.IsLocal()
|
|
// Attribute the charge to the teammate + the thread the draft is for.
|
|
reqCtx := c.Request.Context()
|
|
{
|
|
meta := models.CreditMeta{Context: models.CreditContext{ThreadID: req.ThreadID}}
|
|
if actor, aerr := middleware.GetUserUUID(c); aerr == nil {
|
|
meta.ActorID = actor
|
|
}
|
|
reqCtx = models.WithCreditMeta(reqCtx, meta)
|
|
}
|
|
|
|
var remaining int
|
|
if local {
|
|
if bal, berr := h.CreditService.GetBalance(reqCtx, *orgID); berr == nil {
|
|
remaining = bal
|
|
}
|
|
} else {
|
|
var cerr error
|
|
remaining, cerr = h.CreditService.Consume(reqCtx, *orgID, credits.CostReplyDraft, "reply_draft", model, 0, idemKey)
|
|
if cerr != nil {
|
|
mapCreditError(c, cerr)
|
|
return
|
|
}
|
|
}
|
|
|
|
system := generation.BuildReplyRules(voice)
|
|
if h.SkillsService != nil {
|
|
if pre := h.SkillsService.EnabledPreamble(c.Request.Context(), *orgID); pre != "" {
|
|
system += "\n\n" + pre
|
|
}
|
|
}
|
|
prompt := buildReplyPrompt(history, contactCtx, req.Instruction)
|
|
result, gerr := h.AIProvider.Complete(c.Request.Context(), generation.CompletionRequest{
|
|
System: system,
|
|
Prompt: prompt,
|
|
Model: model,
|
|
})
|
|
if gerr != nil {
|
|
if !local {
|
|
if bal, rerr := h.CreditService.Grant(reqCtx, *orgID, credits.CostReplyDraft, "reply_draft_refund"); rerr == nil {
|
|
remaining = bal
|
|
}
|
|
}
|
|
errx.JSON(c, errx.New(errx.ServiceUnavailable, "The reply drafter is temporarily unavailable. Your credits were not charged."))
|
|
return
|
|
}
|
|
|
|
// Usage-based settle: charge any overage beyond the flat minimum from the
|
|
// actual token usage (best-effort; the delivered draft never fails).
|
|
charged := 0
|
|
if !local {
|
|
charged = credits.CostReplyDraft
|
|
if extra, serr := h.CreditService.SettleUsage(reqCtx, *orgID, credits.CostReplyDraft, result.Model, result.TokensUsed, "reply_draft", settleKey(idemKey)); serr == nil && extra > 0 {
|
|
remaining -= extra
|
|
charged += extra
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"text": result.Text,
|
|
"credits_remaining": remaining,
|
|
"credits_charged": charged,
|
|
"tokens_used": result.TokensUsed,
|
|
"model": result.Model,
|
|
})
|
|
}
|
|
|
|
// buildThreadContext renders the thread oldest-first and returns the
|
|
// counterpart email (the most recent sender) to look up as a contact.
|
|
func buildThreadContext(msgs []models.MessageGrounding) (string, string) {
|
|
counterpart := ""
|
|
if last := msgs[len(msgs)-1]; len(last.FromAddr) > 0 {
|
|
counterpart = last.FromAddr[0]
|
|
}
|
|
return unibox.RenderGrounding(msgs), counterpart
|
|
}
|
|
|
|
// contactContext returns a compact grounding block for the counterpart contact,
|
|
// or "" if none is found.
|
|
func (h *Handler) contactContext(c *gin.Context, userID, orgID uuid.UUID, email string) string {
|
|
if email == "" || h.ContactService == nil {
|
|
return ""
|
|
}
|
|
res, xerr := h.ContactService.Search(c.Request.Context(), orgID.String(), "", "", "5", models.SearchContacts{Query: email})
|
|
if xerr != nil || res == nil || len(res.Data) == 0 {
|
|
return ""
|
|
}
|
|
detail, dxerr := h.ContactService.GetDetail(c.Request.Context(), userID, &orgID, res.Data[0].ID)
|
|
if dxerr != nil || detail == nil {
|
|
return ""
|
|
}
|
|
var b strings.Builder
|
|
name := strings.TrimSpace(detail.FirstName + " " + detail.LastName)
|
|
if name != "" {
|
|
fmt.Fprintf(&b, "Contact: %s", name)
|
|
if detail.Company != "" {
|
|
fmt.Fprintf(&b, " at %s", detail.Company)
|
|
}
|
|
b.WriteString("\n")
|
|
}
|
|
if len(detail.CustomFields) > 0 {
|
|
parts := make([]string, 0, len(detail.CustomFields))
|
|
for k, v := range detail.CustomFields {
|
|
parts = append(parts, k+": "+v)
|
|
}
|
|
fmt.Fprintf(&b, "Known details: %s\n", strings.Join(parts, ", "))
|
|
}
|
|
if len(detail.Campaigns) > 0 {
|
|
names := make([]string, 0, len(detail.Campaigns))
|
|
for _, cp := range detail.Campaigns {
|
|
names = append(names, cp.Name)
|
|
}
|
|
fmt.Fprintf(&b, "In your campaigns: %s\n", strings.Join(names, ", "))
|
|
}
|
|
return strings.TrimSpace(b.String())
|
|
}
|
|
|
|
func buildReplyPrompt(history, contactCtx, instruction string) string {
|
|
var b strings.Builder
|
|
b.WriteString("Thread so far (oldest first):\n\n")
|
|
b.WriteString(history)
|
|
if contactCtx != "" {
|
|
b.WriteString("\n\n")
|
|
b.WriteString(contactCtx)
|
|
}
|
|
b.WriteString("\n\nWrite a reply to the most recent message in this thread.")
|
|
if strings.TrimSpace(instruction) != "" {
|
|
fmt.Fprintf(&b, " The user wants: %s", strings.TrimSpace(instruction))
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// mapCreditError writes the standard 402/429 for a credit consume error.
|
|
func mapCreditError(c *gin.Context, err error) {
|
|
switch {
|
|
case isInsufficientCredits(err):
|
|
paymentRequiredJSON(c, "You're out of AI credits. Add more to keep using AI features.")
|
|
case isCapExceeded(err):
|
|
errx.JSON(c, errx.New(errx.TooManyRequests, "AI usage limit reached, please try again later."))
|
|
default:
|
|
errx.JSON(c, errx.InternalError())
|
|
}
|
|
}
|