Message Translation lets a business hold a real conversation with customers who write in another language. A message a customer sends in Spanish is translated into the team's language so the team can read it, and a reply the team writes in English goes back to the customer in Spanish. It is set up at Dashboard → Settings → Languages → Customer Messages.
It is built for the business that offers its site in a second language but doesn't speak it: the Spanish version of the site brings in Spanish-speaking leads, and this is how the team answers them.
Settings
Three settings, all on the Customer Messages card of the Languages settings page:
| Setting | Options | Default |
|---|---|---|
| Translation | Show a Translate button on each message · Translate new messages automatically · Off | Translate button |
| Your team's language | any language the site offers, plus the full language catalogue | the site's base language |
| Daily limit for automatic translations | 1 – 10,000 (shown only in automatic mode) | 200 |
- Translate button translates a message only when someone on the team asks for it. Nothing is spent until a person clicks. This is the default so that turning the feature on never starts spending AI credits by itself.
- Automatic translates each incoming message as it arrives, so it is already in the team's language when someone opens it.
- Off hides the feature completely — no buttons, no language badges, and no extra work on any page.
Translation uses the site's AI provider for the translation task (Settings → API Keys, or managed AI), the same one that translates the site's pages. When no provider is set up, the card says so and nothing is translated in any mode.
What it costs, and the daily limit
Every translation is one call to the AI provider (occasionally two, when the first attempt fails the quality check) and uses that provider's credits or the plan's AI allowance.
Automatic translation is capped per day because visitors, not the team, decide how many messages arrive — a burst of form submissions should not become an unbounded bill. Once the day's limit is reached, new messages wait untranslated with a note saying why, and anyone on the team can still translate them by hand. Translations a person asks for are never limited. The count resets at midnight UTC.
Several things are never sent to the provider at all, so they cost nothing:
- Text that is already in the team's language — judged by the text itself, so an English answer on a submission from the Spanish page is left alone.
- Text with no words in it: phone numbers, dates, reference numbers.
- A message that already has a current translation. Asking twice, or an automatic translation and a click landing at the same moment, pays once.
Recognising the customer's language
The site works out which language a message is in on its own server, without an AI call:
- By script — Korean, Japanese, Chinese, Arabic, Persian, Urdu, Russian, Ukrainian, Thai, Hebrew, Greek, Hindi and Bengali are recognised from their alphabet alone. This works even for a language the site doesn't offer: a Korean message on an English-only site is still recognised as Korean.
- By common words — for languages written in the Latin alphabet (English, Spanish, French, Portuguese, German, Italian, Vietnamese, Dutch, Polish, Romanian, Swedish, Turkish, Czech, Indonesian, Filipino), short everyday words such as "the", "que" and "und" decide. The choice is made among the site's own languages, the team's language, and English, Spanish, French, Portuguese, German, Italian and Vietnamese.
When a message doesn't say clearly enough — "ok", a name, a phone number, a single word — the site does not guess. It uses the language of the page the customer was on instead, because a confident wrong guess would send the reply in the wrong language.
Messages in a non-Latin script and the Spam Shield. The Spam Shield's content check holds a message written mostly in a non-Latin script (Korean, Chinese, Arabic, Russian…) as spam — a form submission in the form's Spam box, a support ticket in the tickets' Spam tab — under the cms.spam.shield.content.flag_non_latin rule, on by default, because most such contact-form traffic on an English-language site is spam. A script the site's own languages are written in never counts, and neither does the team's language while this feature is on: a site that publishes Korean, or whose team reads Russian, receives those messages normally. A script the site has no language in — Korean on an English and Spanish site — is still held: the message keeps its recorded language, but nothing is translated automatically or sent for it while it is held; a held form submission still offers Translate so someone can read it to judge it, and Not spam returns either to the team. A site that expects customers writing in other scripts should check its Spam box, or add the language.
Form submissions
Every submission records the language the visitor wrote in — on the public form, on a paid form, and through the REST API. It is read from the visitor's own words when they say it clearly, so Spanish typed on the English page is recorded as Spanish; otherwise it is the language of the page they submitted from (the site's base language for an API submission, which has no page). The language is recorded whatever the translation setting, even Off: it is a fact about the lead, and it is what replies and the autoresponder will use. A paid form records it before checkout, because the rest of its pipeline runs later from Stripe, when nothing knows which page the visitor was on.
With the CRM on, the contact gets the same language. It is filled once and never overwritten by a later guess — a Spanish speaker's short English follow-up does not switch the replies they get to English. Merging two contacts keeps it.
What is translated. Only the visitor's own words:
- Paragraph fields (textarea) — always, when they hold words.
- One-line text fields — only when the answer reads as a sentence (three words or more). A name or a company name is never sent, even a long one.
- Never: email, phone, website, number, date, time, address, name, the choice fields (whose options the owner wrote), checkboxes, files, signatures, hidden fields, ratings and the rest.
In automatic mode, a submission is translated as it arrives — after every spam check, so a quarantined or honeypot submission is never paid for, and before the owner's notification email, so the email can carry the translation. The visitor is waiting while this happens, so it is limited to 10 seconds for all answers together: a provider that is slow or down never holds up the submission, and the owner email always goes out. An answer not translated in time is marked as such and can be translated by hand. Submissions count against the daily limit. A paid form is translated when its payment comes in, never before — an abandoned checkout costs nothing.
The owner's notification email says when the visitor wrote in another language:
- Translated — each translated answer shows in the team's language with the visitor's original words underneath, under a note naming the language ("Translated from Spanish").
- Not translated — a note names the language and links to the submission in the dashboard; when an automatic translation failed or was skipped (daily limit, too slow, the AI allowance used up), it says why.
- In the team's language, or the feature Off — the email is exactly as before.
A form set not to save submissions is not translated automatically: there is nowhere to keep the translation, and nothing to count it against the daily limit. Its email names the language and says why it wasn't translated. With the CRM on, its answers are still on the contact's timeline, and anyone on the team can translate them there.
The thank-you and the autoresponder
What a visitor reads after submitting — the form's success panel (its Confirmation heading and message) and its Autoresponder email — is written in the language they are answered in:
- The language they wrote in, when the site publishes it. Spanish typed on the English page gets the Spanish thank-you and a Spanish email.
- Otherwise the page they were on. A Korean visitor on an English and Spanish site is answered in the language of the page they used — the site has no Korean copy to send.
- With the feature Off, always the page they were on, as before.
- A paid form answers in the language recorded at submit, even though its email goes out later from Stripe's payment confirmation.
The words are the owner's, translated with the rest of the form. The confirmation heading and message and the autoresponder's subject and body are part of the form's translations, like its field labels: Forms → Auto Translate translates them, and translations:status reports them. A part the owner left blank uses the built-in wording ("Submitted!"), which is already translated. A part with no translation in the visitor's language goes out in the site's base language — the rest of the email is still in theirs.
In automatic mode, missing copy is translated the first time it is needed — once, after the visitor's page has responded, so nobody waits on it. The autoresponder goes out as soon as the translation is in; every later visitor in that language reads the stored copy, so it is paid for once. The thank-you panel the first visitor sees was decided before the translation existed, so it shows the base-language copy; from the next visitor on it is translated. With the Translate button setting (the default) no visitor ever spends AI credit on this — translate the form instead.
When the owner edits the English, the old translation stops being used (the install's stale-translation setting under Settings → Languages decides, as it does for every page). In automatic mode the new English is translated on the next submission — unless the site is set to only report out-of-date translations, in which case the current English goes out until someone translates it.
Merge tags survive translation. A translation that renames {first_name} is refused, because a renamed tag would print nothing ("Hola ,"). The base-language copy goes out instead. A failed translation is not retried for an hour, so a provider that keeps refusing does not cost a call per submission. Editing the English makes it eligible again straight away.
Reading a message in the dashboard
A customer's message shows in the team's language wherever it appears:
- Forms → a form's submissions — each answer in the submission card.
- Unified Inbox — the submission pane, and the one-line preview in the conversation list. Searching the inbox matches both the translation and the visitor's own words.
- CRM → a contact's timeline — the answers on each form-submission entry.
Only the visitor's own words are offered for translation — the same answers the automatic pass translates and the owner email reports on, so the three never disagree. A name, an email address or a phone number never gets a Translate button.
What each answer shows:
| The answer | What the team sees |
|---|---|
| Translated | the translation, a "Translated from Spanish" note, Show original (the visitor's words underneath) and Retranslate |
| Not translated yet | the visitor's words, "Written in Spanish", and Translate |
| Edited since it was translated | the visitor's current words and Translate — never the old translation, which describes words the record no longer holds |
| Translation failed | the visitor's words, the reason (the provider's error, the daily limit, too slow at submit) and Translate |
| In the team's language | exactly as before |
A language badge sits on the submission card, the inbox pane and the inbox list row whenever the customer wrote in a language other than the team's. Without an AI provider set up, the badge and "Written in …" still show but there are no buttons. With the feature Off, none of it shows and the pages read nothing extra.
Translate and Retranslate are a person asking, so they are never held to the daily limit. Each button is disabled while its own request runs. Clicking twice pays once: a second Translate finds the translation already made, and a second Retranslate — queued behind the first, or a colleague's at the same moment — finds that the translation it was aimed at has already been replaced, and does nothing. When a click fails, the team gets a message saying why, and a failed Retranslate keeps the translation they had.
Where a translation is kept. On the submission. A translation made from the CRM timeline is the same one the Forms page and the Inbox show. For a form that keeps no submissions the timeline entry is the only copy of the answers, so the translation is kept on the entry — and deleting the contact deletes it.
Replying in the customer's language
The team writes in its own language; the customer receives the reply in theirs, and only in theirs — no English copy underneath. This covers the Unified Inbox reply to a form submission and the CRM's Send Email (from a contact page, a company page or the Prospecting list).
Choosing the language. When the customer wrote in another language, the composer shows Reply in (the CRM box says Send in), starting on the customer's language. It also lists the site's other languages, for when the language was recognised wrongly, and Don't translate, which sends exactly what was typed. A line under it says what Send will do. For a customer who writes in the team's language there is no choice to make, and the composer looks as it always did. Without an AI provider set up, the composer says the reply will go out exactly as written. If the choice stops being offered while a reply is being written — translation switched off, the AI provider removed, the team's language changed — Send refuses once and says so, and the picker starts again from what is offered now; a second Send is then a choice made against what is on screen (with no picker left, it sends exactly as written).
Everything the customer reads is translated. The subject and the opening line ("This is a reply to the message you sent us." in the Inbox, "Hi Juan," in the CRM) are translated together with the message, rather than taken from the dashboard's own translations. That way a customer writing in a language the site doesn't offer (Korean on an English and Spanish site) still gets a fully Korean email, and the greeting matches the message's tone. The email is marked as being in that language.
If the reply can't be translated, nothing is sent. The composer keeps what was typed, says why (the provider's error, the AI allowance used up, or that it took too long), and offers Send in English instead — sending the team's own words is always a choice someone makes, never a fallback. All of it is translated or none of it is sent: a Spanish message under an English subject is not sent. A Send waits at most 40 seconds for its translation.
What the record holds. The Inbox thread and the CRM timeline record what was actually sent, in the customer's language — the record of an email is the email. The staff member's own words are kept beside it, so the thread and the timeline show the team what they wrote, with a Sent in Spanish note and Show what was sent one click away. A reply typed in the customer's language already is sent as typed and is not translated again.
A double-clicked Send sends once. While a reply is being translated and sent, a second Send of the same message is refused — it is not translated or sent again — and the same message to the same customer is refused for two minutes afterwards ("This message was just sent."). That holds even when the email went out but keeping the record of it failed: pressing Send again would deliver it twice. A different message is never held up.
What it costs. A translated reply makes one AI call for each part — the subject, the opening line and the message, so usually three. A reply sent as written, in the customer's language already, or with Don't translate costs nothing. Replies are a person's action, so they are never held to the daily limit for automatic translations.
When the customer answers
With an inbox reply address set up (Inbox → Settings → Answers by email), the customer's emailed answer comes back into the CMS instead of the site's mailbox: an answer to an Inbox reply joins that submission's conversation in the Unified Inbox, and an answer to a CRM email is logged on the contact's timeline as Email received. It works whether or not translation is on — translation is what makes it readable.
How the answer finds its conversation. Each reply asks to be answered at the reply address with the conversation's private code after a + (r-k3x9m2q7wd+…@in.example.com). The code is random — never the submission or contact number — so nobody can write into a conversation by guessing it. A CRM email sent from a staff member's own mailbox keeps its usual behaviour: the answer goes back to that person, as an ordinary email should.
What is kept. Only what the customer wrote this time — the quoted history their mail client appended ("El lun, 19 oct … escribió:") is cut away, so it is neither shown as their new words nor paid for. The address it came from is kept too, and the Inbox points it out when it isn't the address the conversation was with (customers answer from another account; their answer is still kept). When the record had no language yet, the answer's language is noted; a known one is never replaced.
Translated on arrival when the site translates automatically (counted against the daily limit), otherwise with the Translate button — exactly like a form submission. The Inbox shows the conversation in order: the submission's answers, each reply sent, each answer received.
The team is told. The answer puts the submission back in Needs attention (status new) and near the top of the list, however old the conversation is. An email goes to the form's notification address — or, for a form with none, to whoever sent the last reply; for a CRM contact, to the contact's owner, else whoever last emailed them — showing the answer in the team's language with the customer's words underneath, or saying why it isn't translated.
What is never recorded. A bounce or an automatic reply (an out-of-office answering the reply) is ignored, so it can't put a lead back in the attention list. The same email handed over twice is recorded once. Mail to the reply address that names no conversation is ignored.
Merged contacts. When two CRM contacts are merged, answers to emails either of them was sent still arrive, on the contact that remains.
Setting it up. The reply address must be at a domain this server receives mail for — the same receiving set up for support tickets under Support Tickets → Settings → Email → This server. The settings page refuses an address the server doesn't receive for, and refuses the support tickets' own inbound address (that mail belongs to tickets). If the server later stops receiving for it, replies quietly go back to asking for answers at the site's usual Reply-To, and the composers say the answer won't come back into the CMS — a Reply-To nobody receives at would lose every answer. Answers arrive through the server's own mail receiving only; a site that receives mail through Postmark's inbound webhook gets answers in its mailbox, as before.
Support tickets
A support ticket is held in the requester's language from the moment it opens.
The ticket's language is recorded when it opens, whatever the translation setting: read from the requester's own words when they say it clearly, else the language of the page they opened it from. A ticket emailed to the support address with too few words to tell has no language. A ticket opened from the Unified Inbox ("Open ticket" on a form submission or a chat) takes the conversation's language — the submission's recorded language, or what the chat visitor wrote in — because its first message is the team's own note, not the customer's words. A ticket a review alert opens is addressed to the team's own alert address, so it takes no language.
Messages in, translated. In automatic mode, the requester's opening message and every reply — from their ticket page or by email — is translated as it arrives, before the team's notification goes out, so the notification shows it in the team's language with the requester's words underneath (or says why it isn't translated). Someone may be waiting on the support form, so this is limited to 10 seconds. A ticket held as spam is never translated; choosing Not spam translates it then. In Translate-button mode nothing is spent until someone clicks.
In the dashboard — the ticket page and the Unified Inbox's ticket pane — the requester's messages show in the team's language with Show original, Translate and Retranslate, exactly like a form submission, and a language badge sits by the ticket's status. The inbox list's one-line preview reads in the team's language too. An internal note is shown as written and never gets a Translate button: it is the team's own text.
Replies out, in their language. The ticket composer (on the ticket page and in the Inbox pane) shows Reply in, starting on the requester's language, with the same choices, the same "everything they read is translated" rule — the subject, the greeting, the button and the reply — and the same Send in English instead when a translation fails, as the Inbox and CRM composers. The thread keeps what was sent — the requester reads that same text on their ticket page — with the staff member's own words beside it. A translated ticket reply makes four AI calls (subject, greeting, button, reply); its heading reuses the translated subject. An internal note is never translated or sent, whatever the composer is set to. A double-clicked Send sends once.
The requester's other emails — the confirmation, the satisfaction survey and the "still need help?" reminder — are written in the ticket's language when the site publishes it, else in the language of the page they are on (the site's base language for one sent from the dashboard or by the daily cron). These come from the dashboard's own translations, not the AI, so they cost nothing. With the feature Off, they are sent exactly as before.
Their ticket page opens in that language too. The button in each of these emails — and in a translated reply — links to the requester's ticket page in the language the email is written in when the site publishes it (/es/support/tickets/…), so a Spanish email never opens an English page. A language the site doesn't publish, and every email with the feature Off, links the base-language page as before.
Replies through the REST API go out exactly as the integration wrote them — never translated. The same reply posted to the same ticket twice within two minutes is refused with 409.
Tickets keep their own reply-by-email address (Support Tickets → Settings → Email); the inbox reply address never takes ticket mail, and translation adds nothing to how a ticket's emailed reply finds its thread.
Automotive leads
A lead from an automotive form (a vehicle inquiry, a test drive, a trade-in, a pre-qualification) records the shopper's language the same way — their own words when they say it clearly, else the page they were on — and gives it to the CRM contact. It is translated off the shopper's request, by the lead's forward task, before the store's email and the copy for the dealer's CRM go out: in automatic mode the store reads the lead in the team's language with the shopper's words underneath, and the CRM copy (ADF/XML) carries both — the translation first, then "Original (Spanish)". With the Translate button setting, or when translation is unavailable, the lead is never held back: it goes out with the shopper's words and a note that no translation is available, and anyone can translate it from Automotive → Leads or the CRM timeline. Both show the same translation, kept on the lead. Details: Automotive.
Privacy and retention
- Translations are always encrypted at rest, whether or not the form itself encrypts its submissions. A form set to encrypt its submissions would otherwise have a readable copy of the customer's words sitting next to them.
- A translation never outlives its message. Deleting a submission deletes its translations — and the kept wording of the replies sent to it; so does the form's retention window, deleting the whole form, and any bulk delete. Deleting a CRM contact deletes the kept wording of the emails sent to them. Emptying the support tickets' Spam tab deletes the translations of those tickets' messages. A daily cleanup (
message-translations:prune) removes anything left behind by a delete that bypassed the normal path. - If the site's encryption key changes (a backup restored onto another server, a rotated key), an unreadable translation shows as untranslated and can be translated again — it never breaks the page.
When the customer edits a message
Each translation remembers exactly which text it was made from. If the original is edited afterwards, the old translation is treated as out of date: it is not shown as current, and the message offers Translate again. Changes to spacing or line endings alone don't count as an edit.
When a translation fails
A failed translation is never silent. The message keeps a note of why — the provider's own error, such as an exhausted AI allowance or an outage — and offers Translate again; a click that fails also says why straight away. Retranslating a message that already has a good translation, and failing, keeps the good one.
For developers
Core, not a feature module. Everything lives in core — forms are core, and the CRM, Unified Inbox and Tickets integrate by checking their own feature flags.
| Piece | Where |
|---|---|
| Settings + the availability gate | App\Support\MessageTranslation\MessageTranslationSettings |
| Local language detection | App\Support\MessageTranslation\MessageLanguageDetector |
| Translate + store | App\Support\MessageTranslation\MessageTranslator |
| Storage | App\Models\MessageTranslation (message_translations table) |
| Per-model wiring | App\Models\Concerns\HasMessageTranslations |
| Orphan cleanup | message-translations:prune (daily LazyCron) |
| Form submissions: fields, language, submit-time translation, owner email, the dashboard's views and staff clicks | App\Support\Forms\FormSubmissionTranslation |
| What a viewer shows for one piece of a message (plain / translated / stale / failed / untranslated) | App\Support\MessageTranslation\MessageTranslationView |
| The viewer itself, and the language badge | <x-message-translation.text>, <x-message-translation.badge> (resources/views/components/message-translation/) |
| Which field types are translated | the translate fact in App\Support\Forms\FormFieldTypes (always / words / never; an unknown type is never translated) |
| The thank-you + autoresponder language, their copy, and the on-demand fill | App\Support\Forms\FormVisitorCopy |
| Replies in the customer's language: the picker's options, translating every part, keeping the author's words | App\Support\MessageTranslation\ReplyTranslation (+ ReplyTranslationFailed) |
| A Send that sends once | App\Support\Mail\SendOnce (+ DuplicateSendException) |
| A sent reply in the author's words, what was sent one click away | MessageTranslationView::forSent() + <x-message-translation.sent> |
| One text a customer wrote (an emailed answer, a ticket message): arrival translation, the viewer, a staff click | App\Support\MessageTranslation\CustomerMessageTranslation |
| A conversation's private reply code | App\Models\Concerns\HasReplyToken (reply_token on form_submissions and crm_contacts) |
| The reply address: the setting, the checks, the per-conversation Reply-To | App\Features\Inbox\Support\InboxReplyAddress |
| Taking the customer's emailed answer | App\Features\Inbox\Support\InboxReplyMailHandler (an InboundMailHandler) + App\Features\Inbox\Mail\CustomerReplyMail |
| Support tickets: the language at open, arrival translation, the viewer and the staff click, the requester's email locale | App\Features\Tickets\Support\TicketTranslation |
| A module lead the CRM timeline translates in place, on the lead itself | the App\Support\MessageTranslation\HoldsCustomerMessage contract (customerMessage(): ?array{field, text, language}) |
| Automotive leads: the language at capture, arrival translation in the forward task, the viewer, the copies that leave the site | App\Features\Automotive\Support\LeadTranslation |
A dashboard surface builds its views with FormSubmissionTranslation::views($form, $data, $holder, $language, $canTranslate) (keyed data.{key}; empty when the feature is off) — eager-load the holder's messageTranslations on a list, and resolve MessageTranslationSettings::available() once per render. It renders each with <x-message-translation.text :view="…" action="method" :params="[…]" />; the component calls method(...params, again, seen). That method hands the click to FormSubmissionTranslation::translateForStaff(), which refuses any field fields() doesn't pick (the name comes from the browser), forces a fresh translation over an unreadable one, and returns a message for the person who clicked when it failed. Retranslate passes the shown row's MessageTranslation::versionToken() — a hash over the row's ciphertext, which changes on every write — and is ignored when the row has moved on. MessageTranslator::$lastFailure carries why the last translateField() produced nothing; a failed Retranslate returns the good row it kept, so the row alone can't say. The CRM timeline translates a form_submission entry on its related submission, or — when related is the Form (a form that keeps no submissions) — on the CrmInteraction itself, or — when related is a module record implementing HoldsCustomerMessage (an automotive Lead) — on that record, through CustomerMessageTranslation with the field and text the record names (translateTimelineRelated(); one eager load per record class), so the module's own page and the timeline share one translation; CrmContact::deleting deletes that contact's entries' translations (MessageTranslation::deleteForMessages()), because the timeline goes by database cascade.
Language columns: form_submissions.language, crm_contacts.language and tickets.language — any short code, not only the site's languages (script detection names Korean on an English-only site). Null means "recorded before this existed".
Bounded calls. MessageTranslator::translateField() / translateFields() take a deadline (a microtime(true) instant) for a caller someone is waiting on. Each provider request gets only the whole seconds left (AiTranslator::translate(…, deadline:) → AiTextService::generate($timeout)); with under MessageTranslator::MIN_CALL_SECONDS left nothing is sent (skipped), and a request cut off by the deadline is failed — both with timed_out. The submit budget is FormSubmissionTranslation::SUBMIT_BUDGET_SECONDS.
Settings keys: messages.translation_mode (off | button | auto), messages.team_language, messages.auto_daily_cap; for the two-way loop, inbox.reply_address and inbox.reply_last_received_at.
The gate. MessageTranslationSettings::enabled() is one Setting read and is what a surface checks first — with the mode off, nothing else runs and nothing queries message_translations. available() (on, plus a provider for the translation task via AiTranslator::available()) gates every Translate button; translatesAutomatically() gates every automatic call.
The data model. The message record always holds the words as written or as sent — the customer's language for an inbound message, the language that actually left for an outbound reply. message_translations holds the other side: one row per (translatable_type, translatable_id, field, target_language), enforced by a unique index, so every write is an upsert.
| Column | Meaning |
|---|---|
field |
which part of the message, e.g. data.message |
source_language / target_language |
what the row was translated from / into |
source_hash |
sha256 of the whitespace-normalised source — the staleness check |
body |
the translation, encrypted cast |
status |
done, failed (the provider was asked and failed), skipped (never sent — the daily cap, or no time left at submit) |
error |
the provider's message, or a code (daily_cap, empty_result, timed_out) that errorMessage() resolves in the reader's language |
origin |
machine, or author — a staff member's own wording of a reply that went out translated |
requested_by |
auto (counted against the cap) or staff |
Using it on a model. Add HasMessageTranslations to the model, then:
// Translate one field into the team language (staff click — never capped).
// $language is what the record says the customer writes in; the field's own
// text is checked too, and wins when it is clearly another language.
MessageTranslator::translateField($submission, 'data.message', $text, $language);
// The site translating on its own — gated on automatic mode and the daily cap.
MessageTranslator::translateField($submission, 'data.message', $text, $language, requestedBy: MessageTranslation::REQUESTED_AUTO);
// Show only a translation of the CURRENT text, never a stale one.
MessageTranslator::current($submission, 'data.message', $text);
// An outgoing reply: never returns the untranslated words on failure.
['text' => $spanish, 'error' => $error] = MessageTranslator::translateText($english, 'en', 'es');
translateText() must be called outside any MailLocale wrap — its error is read by the person pressing Send. After sending, recordAuthorOriginal() keeps the staff member's own wording against the sent text, so the thread shows them their words rather than a translation of a translation.
A composer that writes to a customer uses ReplyTranslation, so every composer behaves alike (the Inbox form reply, the CRM's Send Email, and ticket replies):
options($customerLanguage)— the picker (customer's language first, then the site's other languages, never the team's); empty when there is no choice to make.defaultFor()is where it starts,allows($choice, …)holds the browser's value to that list,unavailableNotice()explains a missing picker when no provider is set up,hint()is the line under it.ReplyTranslation::AS_WRITTEN('') is "Don't translate".translate(['subject' => …, 'greeting' => …, 'body' => …], $language)— every part the customer reads, composed in the team's language (__($key, $replace, $team)) and translated throughtranslateText()with oneSEND_BUDGET_SECONDSdeadline for all of them. All or nothing: it throwsReplyTranslationFailed(its message is for the sender) and the caller sends nothing. The chrome is never read from a dashboard lang pack: a language outsidesite.languageswould get English chrome (MailLocale::stored()falls back to the site default), and a pack's register (the maintained Spanish pack is informal) would clash with the translator's.- Send under
MailLocale::stored($language, …), record what was sent, thenrecordOriginals($record, $written, $sent, $language)— it skips a part that went out unchanged. Sent as written,writtenLanguage($body)names the language for the greeting and<html lang>(the detected language of the words, else the team's; null with the feature off, which keeps the old behaviour byte for byte). - Wrap the whole thing — translate included — in
SendOnce::run($destination, $content, $send): a lock on (destination, content) for the duration and aREPEAT_SECONDSmemory after a success, both throwingDuplicateSendException. A send that fails before the message leaves is not remembered;$sendis handed a$deliveredcallback to call the moment the message has gone, so record-keeping that fails after it (the Inbox reply row, the CRM log and tracking pixel) cannot let Send deliver it twice. refusal($choice, $customerLanguage)— what to show when the browser's choice is notallows()ed: "Choose a language to send this in." while there is a picker, or, with none left (a kept choice from before translation was switched off), that Send again sends as written. Every composer checks it before sending and resets its language property todefaultFor()on a refusal, so a kept choice can never leave Send refused with nothing to change.
CrmContactMail takes language (its <html lang>, and the language of the default "Hi :name," greeting — resolved with an explicit locale, so Mail::fake() and a queued render see it too) and greeting (the translated line, which wins). With neither it renders exactly as before, which the CRM's automated emails rely on. The CRM timeline reads an email_sent entry's author rows from the interaction (subject, body); the Inbox reads them from InboxFormReply (body), which now uses HasMessageTranslations. Replies go with their submission by database cascade, so InboxServiceProvider deletes a submission's reply translations as it is deleted (FormSubmission::deleting → MessageTranslation::deleteForMessages(), only that submission's replies — a retention prune deletes thousands one at a time) and prunes InboxFormReply orphans after a Form delete.
The two-way loop. HasReplyToken::replyToken() mints a 40-character lower-case random token on first use with a conditional update (whereNull('reply_token')) and reads it back, so two first replies at once agree on one; a contact merge carries the duplicate's token to a survivor that has none, and when the survivor has its own, CrmMerge keeps the duplicate's in the merged timeline entry's meta.duplicate.reply_token, where InboxReplyMailHandler looks after the two token columns (mergedInto()), so neither contact's emails lose their answers. InboxReplyAddress::forToken($token) is the Reply-To — null unless configured(), which re-checks on every send that the Inbox feature is on, the address's domain is in InboundMailPipeline::domains() and it isn't the tickets' inbound address. The part before the @ is one word of letters, digits, - and _ (what the server's receiving gate takes), at most InboxReplyAddress::MAX_LOCAL_LENGTH (23) characters so + and the token fit the 64-character limit. InboxFormActions::reply() sets it through CampaignMailer::withReplyTo(); SendsContactEmail passes it as CrmContactMail's replyToAddress (an envelope Reply-To) only for a send through the site's mailer. InboxReplyMailHandler is tagged InboundMailHandler::TAG in InboxServiceProvider and accepts only the reply address (case- and plus-part-insensitive), so it and the tickets' handler can never both accept a recipient. It reads the message through the Tickets module's InboundEmailMessage (isAutomated(), hasSystemSender(), replyBody() — QuoteStripper — and messageId()), lower-cases the token, and files by form_submissions.reply_token then crm_contacts.reply_token: an InboxFormReply with direction = in, from_email and email_message_id (unique per submission — the insert itself is the dedupe, with a pre-check), or an email_received CrmInteraction whose meta holds from and message_id (deduped under a per-contact cache lock). A message with no Message-ID is deduped on a sha1: fingerprint of sender, date, subject and body. Each conversation takes at most 10 answers in 10 minutes. Outcomes are the command's one-line ok … / ignored <reason> (no_token, automated_message, unknown_thread, spam_thread, empty_body, duplicate_message, rate_limited); a notification that fails to send is reported, never thrown — the answer is already kept. inbox.reply_last_received_at is what the settings card shows. email_received is in CrmInteraction::TOUCH_TYPES (hearing back bumps last_contacted_at).
Tickets. TicketMessage uses HasMessageTranslations (field body). TicketActions::open() takes an optional language in $details: present (even null) it is the caller's word; absent, TicketTranslation::languageAtOpen() reads it from the message (detector, then cms.visitor_language when a public page set it, else null). open(), customerReply() and a held-at-intake restoreFromSpam() call TicketTranslation::translateOnArrival() — CustomerMessageTranslation::translateOnArrival(…, deadline:) with ARRIVAL_BUDGET_SECONDS — before the owner / assignee emails, which quote a customer message through TicketTranslation::view() and MessageTranslationView::staffNotice() (shared with CustomerReplyMail). TicketActions::staffReply($ticket, $body, $userId, $internal, $files, $language) returns before any translation for an internal note; a public reply checks ReplyTranslation::allows(), then inside SendOnce::run('ticket-reply:{id}', language + body + attachment names and sizes, …) translates TicketEmails::staffReplyParts() (subject / intro / button, composed in the team language) plus the body, stores the sent body, recordOriginals(), and calls TicketEmails::sendStaffReply($ticket, $message, $translated, $language) — $language is the send language, or writtenLanguage($body) sent as written (null with the feature off: MailLocale::visitor(), no <html lang>, as before). The automatic requester emails compose under TicketTranslation::toRequester(): MailLocale::siteLanguage($ticket->language) ?? MailLocale::forVisitor(), or plain MailLocale::visitor() with the feature off. TicketActions::emptySpam() prunes TicketMessage orphans after its cascade delete. The API's SupportTicketsController::reply() maps DuplicateSendException to 409.
The thank-you and the autoresponder ARE content translations — the exception. They are the owner's copy, not a customer's message: Form::VISITOR_COPY keys (actions.confirmation.heading, actions.confirmation.message, actions.autoresponder.subject, actions.autoresponder.body) in the form's translations bag, offered by Form::visitorCopyKeys() only where a visitor can read them (no panel keys for a redirect; no autoresponder keys while it is off; nothing for a blank part). They go through HasTranslations like every other model translation, so provenance, the render policy, translations:status / import / fill and Forms → Auto Translate all reach them with no extra wiring. FormVisitorCopy::language($written, $page) picks the language: the written one if MailLocale::siteLanguage() finds it in site.languages, else the page (null for a paid form, whose webhook has no page), else the site default; with the feature off, the page. FormAutoresponderMail takes that language outright (localized($key, $lang), __($text, [], $lang)) instead of reading the active locale, so it reads the same however it is rendered; its <html lang> is the body's real language. FormVisitorCopy::fill() runs in a defer() from FormSubmitActions::sendAutoresponder(), under a per-form-and-language cache lock, re-reads the form under the lock, writes only keys whose English is unchanged since the call (so a translation is never stamped against English it was not made from), saves quietly (no response-cache flush from a visitor's submit), and remembers a failure for RETRY_AFTER_SECONDS keyed by the English it failed on. TranslationGuard treats {merge_tag} names like :placeholders — a changed one is rejected on every translation path.
Customer messages are not content translations. No {key}__{lang} key, no provenance bag, never rendered on the public site — so they don't register in TranslationSurfaces. The translation itself is AiTranslator::translate() on the translation task, with a TranslationContext role describing a customer's message (or a business's reply), so it carries the same guard, glossary and Site Knowledge as page translation.