Merge pull request #716 from warmbly/feat/localized-optout-and-unsubscribe-page

feat: read opt-out replies and serve the unsubscribe page in every tagging language
This commit is contained in:
Matthew Meszaros
2026-09-27 12:10:48 +00:00
committed by GitHub
16 changed files with 2408 additions and 145 deletions
+1 -1
View File
@@ -140,7 +140,7 @@ Tagging reads English, German, French, Spanish, Italian and Dutch replies out of
Adding a language does two things:
- **It is named to the classifier**, alongside each message, so it knows what to expect. It is a hint, not a translation: nothing about the message or the questions changes, and the built-in questions stay in English. Any of 44 languages can be named: Arabic, Bengali, Bulgarian, Catalan, Chinese, Croatian, Czech, Danish, Dutch, English, Estonian, Filipino, Finnish, French, German, Greek, Hebrew, Hindi, Hungarian, Indonesian, Italian, Japanese, Korean, Latvian, Lithuanian, Malay, Norwegian, Persian, Polish, Portuguese, Romanian, Russian, Serbian, Slovak, Slovenian, Spanish, Swahili, Swedish, Tamil, Thai, Turkish, Ukrainian, Urdu and Vietnamese.
- **It adds that language to the reading done before any call**, for these 27: Arabic, Chinese, Czech, Danish, Dutch, Finnish, French, German, Greek, Hebrew, Hindi, Hungarian, Indonesian, Italian, Japanese, Korean, Norwegian, Polish, Portuguese, Romanian, Russian, Spanish, Swedish, Thai, Turkish, Ukrainian and Vietnamese. The picker marks the others as a classifier hint only.
- **It adds that language to the reading done before any call**, for every one of them. English is always read.
The reading before any call covers:
+3 -1
View File
@@ -37,6 +37,8 @@ The token carries 128 bits from a cryptographic random source, which is more tha
A mailbox or campaign with a verified [custom tracking domain](/guides/mailboxes/#custom-tracking-domain) serves its unsubscribe link there, so a recipient reads `https://t.yourdomain.com/unsubscribe/...` rather than an address on the platform. The campaign's own domain wins over the mailbox's, and only a verified one is used. The page itself is the same page, named after you: it carries no logo, no product name and no scripts, because the email came from your mailbox and not from a platform.
The page speaks the recipient's language. It follows the language their browser asks for, in any of the [tagging languages](/guides/inbox-tagging/#tagging-languages) (German, French, Japanese, Arabic and the rest, with Traditional Chinese for Taiwan and Hong Kong), and falls back to English for a language it does not have. Right-to-left languages read right to left. Nothing about this is a setting: a German recipient reads German whatever language your workspace or campaign is in.
Without a custom domain the link stays on the instance's own API address. Either way it is the sender's infrastructure, not a third party's, and the recipient's opt-out reaches the same suppression list.
Unsubscribe links travel with a [workspace export](/guides/workspace-export-import/), in the Campaigns group, so moving to another instance does not break a link someone is still holding. Keep serving the tracking domain they were minted on, or they arrive at a host that no longer answers.
@@ -61,7 +63,7 @@ Two things to know. Gmail only shows its button for mail it already classes as b
## Replies that ask to stop
Warmbly reads every reply to a campaign email. One that asks to stop, in wording such as "unsubscribe", "remove me", "stop emailing me", "opt out" or "do not contact", puts the sender on the suppression list immediately. German replies are read the same way, whatever languages the workspace tags in: "bitte austragen", "keine weiteren E-Mails" or "löschen Sie meine Daten" opt the sender out too. Only the new text of the reply is read: the quoted history below it carries your own opt-out wording and is ignored. Phrases match on whole words, so "stop by our booth" does not opt anyone out.
Warmbly reads every reply to a campaign email. One that asks to stop, in wording such as "unsubscribe", "remove me", "stop emailing me", "opt out" or "do not contact", puts the sender on the suppression list immediately. Replies in every tagging language are read the same way, whatever languages the workspace tags in: "bitte austragen", "désinscrivez-moi", "denme de baja", "wypiszcie mnie", "отпишите меня" or "配信停止" opt the sender out too. The quoted history is cut in each of those languages as well, so a campaign that carries its own opt-out line in German or Japanese does not opt out everyone who answers it. Only the new text of the reply is read: the quoted history below it carries your own opt-out wording and is ignored. Phrases match on whole words, so "stop by our booth" does not opt anyone out.
Only a person answering you can opt out this way: someone replying in one of your campaign threads, or one of your contacts writing to you. Everything else that lands in a mailbox is left alone, even when it says "unsubscribe":
+90 -30
View File
@@ -5,6 +5,8 @@ import (
"errors"
"html/template"
"net/http"
"sort"
"strconv"
"strings"
"time"
@@ -42,15 +44,12 @@ func (h *Handler) UnsubscribePage(c *gin.Context) {
if !ok {
return
}
t := unsubCopyFor(c.GetHeader("Accept-Language"))
if claims.ContactID == uuid.Nil {
renderUnsubPage(c, http.StatusOK, unsubView{Title: "This was a test email", Body: "Test sends carry a link that is not tied to anyone, so there is nothing to unsubscribe."})
renderUnsubPage(c, http.StatusOK, unsubView{Title: t.TestTitle, Body: t.TestBody})
return
}
renderUnsubPage(c, http.StatusOK, unsubView{
Title: "Unsubscribe from these emails?",
Body: "Confirm and you will not receive further emails from this sender.",
Confirm: c.Request.URL.Path,
})
renderUnsubPage(c, http.StatusOK, unsubView{Title: t.ConfirmTitle, Body: t.ConfirmBody, Confirm: c.Request.URL.Path})
}
// unsubscribeBodyLimit caps the public POST bodies. The engine-wide limit is
@@ -63,6 +62,7 @@ func (h *Handler) UnsubscribeSubmit(c *gin.Context) {
oneClick := strings.EqualFold(strings.TrimSpace(c.PostForm("List-Unsubscribe")), "One-Click")
confirmed := c.PostForm("confirm") == "1"
t := unsubCopyFor(c.GetHeader("Accept-Language"))
claims, err := h.verifyUnsubscribeToken(c.Request.Context(), c.Param("token"))
if err != nil {
if oneClick {
@@ -76,7 +76,7 @@ func (h *Handler) UnsubscribeSubmit(c *gin.Context) {
c.Status(http.StatusOK)
return
}
renderUnsubPage(c, unsubStatus(err), unsubInvalid(err))
renderUnsubPage(c, unsubStatus(err), unsubInvalid(t, err))
return
}
if claims.ContactID == uuid.Nil {
@@ -84,18 +84,14 @@ func (h *Handler) UnsubscribeSubmit(c *gin.Context) {
c.Status(http.StatusOK)
return
}
renderUnsubPage(c, http.StatusOK, unsubView{Title: "This was a test email", Body: "Test sends carry a link that is not tied to anyone, so there is nothing to unsubscribe."})
renderUnsubPage(c, http.StatusOK, unsubView{Title: t.TestTitle, Body: t.TestBody})
return
}
// A browser POST without the confirm field is not the button: show the
// confirm page again rather than act on it.
if !oneClick && !confirmed {
renderUnsubPage(c, http.StatusOK, unsubView{
Title: "Unsubscribe from these emails?",
Body: "Confirm and you will not receive further emails from this sender.",
Confirm: c.Request.URL.Path,
})
renderUnsubPage(c, http.StatusOK, unsubView{Title: t.ConfirmTitle, Body: t.ConfirmBody, Confirm: c.Request.URL.Path})
return
}
@@ -114,14 +110,10 @@ func (h *Handler) UnsubscribeSubmit(c *gin.Context) {
return
}
if xerr != nil {
renderUnsubPage(c, http.StatusOK, unsubView{Title: "We couldn't process that link", Body: "The link is no longer valid. Reply to the email instead and the sender will stop."})
renderUnsubPage(c, http.StatusOK, unsubView{Title: t.FailedTitle, Body: t.FailedBody})
return
}
renderUnsubPage(c, http.StatusOK, unsubView{
Title: "You've been unsubscribed",
Body: "You will not receive further emails from this sender.",
Resubscribe: c.Request.URL.Path + "/resubscribe",
})
renderUnsubPage(c, http.StatusOK, unsubView{Title: t.DoneTitle, Body: t.DoneBody, Resubscribe: c.Request.URL.Path + "/resubscribe"})
}
func (h *Handler) UnsubscribeUndo(c *gin.Context) {
@@ -130,15 +122,16 @@ func (h *Handler) UnsubscribeUndo(c *gin.Context) {
if !ok {
return
}
t := unsubCopyFor(c.GetHeader("Accept-Language"))
if claims.ContactID == uuid.Nil {
renderUnsubPage(c, http.StatusBadRequest, unsubInvalid(unsublink.ErrInvalid))
renderUnsubPage(c, http.StatusBadRequest, unsubInvalid(t, unsublink.ErrInvalid))
return
}
if xerr := h.AdvancedService.Resubscribe(c.Request.Context(), claims.OrgID, claims.ContactID); xerr != nil {
renderUnsubPage(c, http.StatusOK, unsubView{Title: "We couldn't process that link", Body: "The link is no longer valid. Reply to the email and the sender can add you back."})
renderUnsubPage(c, http.StatusOK, unsubView{Title: t.FailedTitle, Body: t.FailedResubscribeBody})
return
}
renderUnsubPage(c, http.StatusOK, unsubView{Title: "You're subscribed again", Body: "The sender can email you as before. You can unsubscribe from any later email."})
renderUnsubPage(c, http.StatusOK, unsubView{Title: t.ResubscribedTitle, Body: t.ResubscribedBody})
}
// errUnsubUnavailable is a ticket lookup that failed rather than a link that
@@ -180,7 +173,7 @@ func (h *Handler) verifyUnsubscribeToken(ctx context.Context, token string) (uns
func (h *Handler) unsubscribeClaims(c *gin.Context) (unsublink.Claims, bool) {
claims, err := h.verifyUnsubscribeToken(c.Request.Context(), c.Param("token"))
if err != nil {
renderUnsubPage(c, unsubStatus(err), unsubInvalid(err))
renderUnsubPage(c, unsubStatus(err), unsubInvalid(unsubCopyFor(c.GetHeader("Accept-Language")), err))
return claims, false
}
return claims, true
@@ -195,14 +188,14 @@ func unsubStatus(err error) int {
return http.StatusBadRequest
}
func unsubInvalid(err error) unsubView {
func unsubInvalid(t unsubCopy, err error) unsubView {
switch err {
case errUnsubUnavailable:
return unsubView{Title: "Try again shortly", Body: "We could not check this link just now. Open it again in a few minutes, or reply to the email and the sender will stop."}
return unsubView{Title: t.RetryTitle, Body: t.RetryBody}
case unsublink.ErrExpired:
return unsubView{Title: "This link has expired", Body: "Reply to the email instead and the sender will stop."}
return unsubView{Title: t.ExpiredTitle, Body: t.ReplyBody}
}
return unsubView{Title: "This unsubscribe link is invalid", Body: "Reply to the email instead and the sender will stop."}
return unsubView{Title: t.InvalidTitle, Body: t.ReplyBody}
}
type unsubView struct {
@@ -210,22 +203,89 @@ type unsubView struct {
Body string
Confirm string // POST target of the confirm button, when shown
Resubscribe string // POST target of the resubscribe button, when shown
// Set by renderUnsubPage from the request's language.
Lang, ConfirmButton, ResubscribeButton string
RTL bool
}
// unsubLanguage picks the page's language from the browser's
// Accept-Language: the most preferred one the page is written in, English
// when none is.
func unsubLanguage(acceptLanguage string) string {
type pref struct {
code string
q float64
}
var prefs []pref
for _, part := range strings.Split(acceptLanguage, ",") {
tag, params, _ := strings.Cut(strings.TrimSpace(part), ";")
q := 1.0
if v, ok := strings.CutPrefix(strings.TrimSpace(params), "q="); ok {
f, err := strconv.ParseFloat(v, 64)
// A qvalue is 0 to 1; NaN fails both comparisons.
if err != nil || !(f >= 0 && f <= 1) {
continue
}
q = f
}
if code := unsubLanguageCode(tag); code != "" && q > 0 {
prefs = append(prefs, pref{code, q})
}
}
sort.SliceStable(prefs, func(i, j int) bool { return prefs[i].q > prefs[j].q })
if len(prefs) > 0 {
return prefs[0].code
}
return "en"
}
// unsubLanguageCode is the page language a tag asks for, or "" for one it
// has none of. Norwegian and Tagalog tags name the language written as nb and
// fil, and Taiwan, Hong Kong and Macau read Traditional Chinese.
func unsubLanguageCode(tag string) string {
parts := strings.Split(strings.ToLower(strings.ReplaceAll(tag, "_", "-")), "-")
code := parts[0]
switch code {
case "no", "nn":
code = "nb"
case "tl":
code = "fil"
case "zh":
for _, p := range parts[1:] {
if p == "hant" || p == "tw" || p == "hk" || p == "mo" {
code = "zh-Hant"
}
}
}
if _, ok := unsubCopies[code]; !ok {
return ""
}
return code
}
func unsubCopyFor(acceptLanguage string) unsubCopy {
return unsubCopies[unsubLanguage(acceptLanguage)]
}
// A neutral page: the email came from the customer's mailbox, so the page
// names no brand and carries no scripts or external assets.
var unsubTemplate = template.Must(template.New("unsubscribe").Parse(`<!doctype html><html lang="en"><head><meta charset="utf-8">
var unsubTemplate = template.Must(template.New("unsubscribe").Parse(`<!doctype html><html lang="{{.Lang}}"{{if .RTL}} dir="rtl"{{end}}><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex"><title>{{.Title}}</title>
<style>body{font-family:system-ui,-apple-system,"Segoe UI",sans-serif;max-width:32rem;margin:4rem auto;padding:0 1.25rem;color:#0f172a;line-height:1.5}
h1{font-size:1.25rem;margin:0 0 .5rem}p{color:#475569;margin:0 0 1.25rem}
button{font:inherit;padding:.55rem 1rem;border-radius:.375rem;border:1px solid #0284c7;background:#0284c7;color:#fff;cursor:pointer}
button.secondary{background:#fff;color:#0f172a;border-color:#cbd5e1}</style></head>
<body><h1>{{.Title}}</h1><p>{{.Body}}</p>
{{if .Confirm}}<form method="post" action="{{.Confirm}}"><input type="hidden" name="confirm" value="1"><button type="submit">Unsubscribe</button></form>{{end}}
{{if .Resubscribe}}<form method="post" action="{{.Resubscribe}}"><button type="submit" class="secondary">Unsubscribed by mistake? Resubscribe</button></form>{{end}}
{{if .Confirm}}<form method="post" action="{{.Confirm}}"><input type="hidden" name="confirm" value="1"><button type="submit">{{.ConfirmButton}}</button></form>{{end}}
{{if .Resubscribe}}<form method="post" action="{{.Resubscribe}}"><button type="submit" class="secondary">{{.ResubscribeButton}}</button></form>{{end}}
</body></html>`))
func renderUnsubPage(c *gin.Context, status int, v unsubView) {
v.Lang = unsubLanguage(c.GetHeader("Accept-Language"))
t := unsubCopies[v.Lang]
v.ConfirmButton, v.ResubscribeButton, v.RTL = t.ConfirmButton, t.ResubscribeButton, t.RTL
c.Header("Vary", "Accept-Language")
c.Header("Cache-Control", "no-store")
c.Header("X-Robots-Tag", "noindex")
c.Status(status)
+931
View File
@@ -0,0 +1,931 @@
package handler
// unsubCopy is the recipient-facing unsubscribe page in one language.
type unsubCopy struct {
RTL bool
TestTitle, TestBody string
ConfirmTitle string
ConfirmBody string
ConfirmButton string
DoneTitle, DoneBody string
ResubscribeButton string
ResubscribedTitle string
ResubscribedBody string
FailedTitle string
FailedBody string
FailedResubscribeBody string
RetryTitle, RetryBody string
ExpiredTitle string
InvalidTitle string
ReplyBody string
}
// unsubCopies is the page in every tagging language (models.MailLanguageNames)
// and Traditional Chinese. The tracking service's own two pages carry the same
// invalid and retry wording in tracking/src/unsubscribe_i18n.rs.
var unsubCopies = map[string]unsubCopy{
"en": {
TestTitle: "This was a test email",
TestBody: "Test sends carry a link that is not tied to anyone, so there is nothing to unsubscribe.",
ConfirmTitle: "Unsubscribe from these emails?",
ConfirmBody: "Confirm and you will not receive further emails from this sender.",
ConfirmButton: "Unsubscribe",
DoneTitle: "You've been unsubscribed",
DoneBody: "You will not receive further emails from this sender.",
ResubscribeButton: "Unsubscribed by mistake? Resubscribe",
ResubscribedTitle: "You're subscribed again",
ResubscribedBody: "The sender can email you as before. You can unsubscribe from any later email.",
FailedTitle: "We couldn't process that link",
FailedBody: "The link is no longer valid. Reply to the email instead and the sender will stop.",
FailedResubscribeBody: "The link is no longer valid. Reply to the email and the sender can add you back.",
RetryTitle: "Try again shortly",
RetryBody: "We could not check this link just now. Open it again in a few minutes, or reply to the email and the sender will stop.",
ExpiredTitle: "This link has expired",
InvalidTitle: "This unsubscribe link is invalid",
ReplyBody: "Reply to the email instead and the sender will stop.",
},
"ar": {
RTL: true,
TestTitle: "كانت هذه رسالة تجريبية",
TestBody: "تحتوي الرسائل التجريبية على رابط غير مرتبط بأي شخص، لذا لا يوجد اشتراك لإلغائه.",
ConfirmTitle: "هل تريد إلغاء الاشتراك في هذه الرسائل؟",
ConfirmBody: "أكِّد ذلك ولن تتلقى أي رسائل أخرى من هذا المرسِل.",
ConfirmButton: "إلغاء الاشتراك",
DoneTitle: "تم إلغاء اشتراكك",
DoneBody: "لن تتلقى أي رسائل أخرى من هذا المرسِل.",
ResubscribeButton: "هل ألغيت الاشتراك عن طريق الخطأ؟ أعد الاشتراك",
ResubscribedTitle: "تم تجديد اشتراكك",
ResubscribedBody: "يمكن للمرسِل مراسلتك كما في السابق. ويمكنك إلغاء الاشتراك من أي رسالة لاحقة.",
FailedTitle: "تعذّرت علينا معالجة هذا الرابط",
FailedBody: "لم يعد هذا الرابط صالحًا. يمكنك بدلًا من ذلك الرد على الرسالة وسيتوقف المرسِل عن مراسلتك.",
FailedResubscribeBody: "لم يعد هذا الرابط صالحًا. ردّ على الرسالة ويمكن للمرسِل إعادة إضافتك.",
RetryTitle: "حاول مرة أخرى بعد قليل",
RetryBody: "تعذّر علينا التحقق من هذا الرابط الآن. افتحه مرة أخرى بعد بضع دقائق، أو ردّ على الرسالة وسيتوقف المرسِل عن مراسلتك.",
ExpiredTitle: "انتهت صلاحية هذا الرابط",
InvalidTitle: "رابط إلغاء الاشتراك هذا غير صالح",
ReplyBody: "يمكنك بدلًا من ذلك الرد على الرسالة وسيتوقف المرسِل عن مراسلتك.",
},
"bg": {
TestTitle: "Това беше тестов имейл",
TestBody: "Тестовите имейли съдържат връзка, която не е свързана с конкретен човек, затова няма абонамент за прекратяване.",
ConfirmTitle: "Отписване от тези имейли?",
ConfirmBody: "Потвърдете и няма да получавате повече имейли от този подател.",
ConfirmButton: "Отписване",
DoneTitle: "Отписахте се успешно",
DoneBody: "Няма да получавате повече имейли от този подател.",
ResubscribeButton: "Отписахте се по погрешка? Абонирайте се отново",
ResubscribedTitle: "Отново сте абонирани",
ResubscribedBody: "Подателят може да ви пише както преди. Можете да се отпишете от всеки следващ имейл.",
FailedTitle: "Не успяхме да обработим тази връзка",
FailedBody: "Връзката вече не е валидна. Вместо това отговорете на имейла и подателят ще спре да ви пише.",
FailedResubscribeBody: "Връзката вече не е валидна. Отговорете на имейла и подателят може да ви добави отново.",
RetryTitle: "Опитайте отново след малко",
RetryBody: "В момента не успяхме да проверим тази връзка. Отворете я отново след няколко минути или отговорете на имейла и подателят ще спре да ви пише.",
ExpiredTitle: "Валидността на тази връзка е изтекла",
InvalidTitle: "Тази връзка за отписване е невалидна",
ReplyBody: "Вместо това отговорете на имейла и подателят ще спре да ви пише.",
},
"bn": {
TestTitle: "এটি একটি পরীক্ষামূলক ইমেইল ছিল",
TestBody: "পরীক্ষামূলক ইমেইলের লিংক কোনো নির্দিষ্ট ব্যক্তির সঙ্গে যুক্ত নয়, তাই আনসাবস্ক্রাইব করার কিছু নেই।",
ConfirmTitle: "এই ইমেইলগুলো থেকে আনসাবস্ক্রাইব করবেন?",
ConfirmBody: "নিশ্চিত করলে এই প্রেরকের কাছ থেকে আপনি আর কোনো ইমেইল পাবেন না।",
ConfirmButton: "আনসাবস্ক্রাইব করুন",
DoneTitle: "আপনার সাবস্ক্রিপশন বাতিল হয়েছে",
DoneBody: "এই প্রেরকের কাছ থেকে আপনি আর কোনো ইমেইল পাবেন না।",
ResubscribeButton: "ভুল করে আনসাবস্ক্রাইব করেছেন? আবার সাবস্ক্রাইব করুন",
ResubscribedTitle: "আপনার সাবস্ক্রিপশন আবার চালু হয়েছে",
ResubscribedBody: "প্রেরক আগের মতোই আপনাকে ইমেইল পাঠাতে পারবেন। পরবর্তী যেকোনো ইমেইল থেকে আপনি আনসাবস্ক্রাইব করতে পারবেন।",
FailedTitle: "আমরা এই লিংকটি প্রক্রিয়া করতে পারিনি",
FailedBody: "লিংকটি আর বৈধ নয়। এর পরিবর্তে ইমেইলটির উত্তর দিন, তাহলে প্রেরক আপনাকে ইমেইল পাঠানো বন্ধ করবেন।",
FailedResubscribeBody: "লিংকটি আর বৈধ নয়। ইমেইলটির উত্তর দিন, তাহলে প্রেরক আপনাকে আবার যুক্ত করতে পারবেন।",
RetryTitle: "কিছুক্ষণ পরে আবার চেষ্টা করুন",
RetryBody: "এই মুহূর্তে আমরা লিংকটি যাচাই করতে পারিনি। কয়েক মিনিট পরে এটি আবার খুলুন, অথবা ইমেইলটির উত্তর দিন, তাহলে প্রেরক আপনাকে ইমেইল পাঠানো বন্ধ করবেন।",
ExpiredTitle: "এই লিংকের মেয়াদ শেষ হয়ে গেছে",
InvalidTitle: "এই আনসাবস্ক্রাইব লিংকটি বৈধ নয়",
ReplyBody: "এর পরিবর্তে ইমেইলটির উত্তর দিন, তাহলে প্রেরক আপনাকে ইমেইল পাঠানো বন্ধ করবেন।",
},
"ca": {
TestTitle: "Aquest era un correu de prova",
TestBody: "Els correus de prova contenen un enllaç que no està vinculat a ningú, de manera que no hi ha cap subscripció per cancel·lar.",
ConfirmTitle: "Voleu deixar de rebre aquests correus?",
ConfirmBody: "Confirmeu-ho i no rebreu més correus d'aquest remitent.",
ConfirmButton: "Cancel·la la subscripció",
DoneTitle: "S'ha cancel·lat la vostra subscripció",
DoneBody: "No rebreu més correus d'aquest remitent.",
ResubscribeButton: "Us heu donat de baixa per error? Torneu-vos a subscriure",
ResubscribedTitle: "S'ha reactivat la vostra subscripció",
ResubscribedBody: "El remitent us pot escriure com abans. Podeu cancel·lar la subscripció des de qualsevol correu posterior.",
FailedTitle: "No hem pogut processar aquest enllaç",
FailedBody: "L'enllaç ja no és vàlid. En lloc d'això, responeu al correu i el remitent deixarà d'escriure-us.",
FailedResubscribeBody: "L'enllaç ja no és vàlid. Responeu al correu i el remitent us podrà tornar a afegir.",
RetryTitle: "Torneu-ho a provar d'aquí a una estona",
RetryBody: "Ara mateix no hem pogut comprovar aquest enllaç. Torneu-lo a obrir d'aquí a uns minuts o responeu al correu i el remitent deixarà d'escriure-us.",
ExpiredTitle: "Aquest enllaç ha caducat",
InvalidTitle: "Aquest enllaç per cancel·lar la subscripció no és vàlid",
ReplyBody: "En lloc d'això, responeu al correu i el remitent deixarà d'escriure-us.",
},
"cs": {
TestTitle: "Toto byl testovací e-mail",
TestBody: "Testovací e-maily obsahují odkaz, který není spojen s žádným příjemcem, takže není z čeho se odhlašovat.",
ConfirmTitle: "Odhlásit se z odběru těchto e-mailů?",
ConfirmBody: "Po potvrzení vám tento odesílatel už nebude posílat další e-maily.",
ConfirmButton: "Odhlásit se",
DoneTitle: "Odběr byl zrušen",
DoneBody: "Od tohoto odesílatele již nebudete dostávat další e-maily.",
ResubscribeButton: "Odhlásili jste se omylem? Přihlásit se znovu",
ResubscribedTitle: "Odběr byl obnoven",
ResubscribedBody: "Odesílatel vám může psát jako dříve. Z odběru se můžete odhlásit v kterémkoli dalším e-mailu.",
FailedTitle: "Tento odkaz se nám nepodařilo zpracovat",
FailedBody: "Odkaz již není platný. Odpovězte místo toho na e-mail a odesílatel vám přestane psát.",
FailedResubscribeBody: "Odkaz již není platný. Odpovězte na e-mail a odesílatel vás může znovu přidat.",
RetryTitle: "Zkuste to prosím za chvíli",
RetryBody: "Tento odkaz se nám teď nepodařilo ověřit. Otevřete jej znovu za několik minut, nebo odpovězte na e-mail a odesílatel vám přestane psát.",
ExpiredTitle: "Platnost tohoto odkazu vypršela",
InvalidTitle: "Tento odkaz k odhlášení je neplatný",
ReplyBody: "Odpovězte místo toho na e-mail a odesílatel vám přestane psát.",
},
"da": {
TestTitle: "Dette var en test-e-mail",
TestBody: "Test-e-mails indeholder et link, der ikke er knyttet til nogen, så der er intet at afmelde.",
ConfirmTitle: "Vil du afmelde disse e-mails?",
ConfirmBody: "Bekræft, så modtager du ikke flere e-mails fra denne afsender.",
ConfirmButton: "Afmeld",
DoneTitle: "Du er nu afmeldt",
DoneBody: "Du modtager ikke flere e-mails fra denne afsender.",
ResubscribeButton: "Afmeldt ved en fejl? Tilmeld dig igen",
ResubscribedTitle: "Du er tilmeldt igen",
ResubscribedBody: "Afsenderen kan skrive til dig som før. Du kan afmelde dig fra enhver senere e-mail.",
FailedTitle: "Vi kunne ikke behandle linket",
FailedBody: "Linket er ikke længere gyldigt. Svar i stedet på e-mailen, så holder afsenderen op med at skrive til dig.",
FailedResubscribeBody: "Linket er ikke længere gyldigt. Svar på e-mailen, så kan afsenderen tilføje dig igen.",
RetryTitle: "Prøv igen om lidt",
RetryBody: "Vi kunne ikke kontrollere linket lige nu. Åbn det igen om et par minutter, eller svar på e-mailen, så holder afsenderen op med at skrive til dig.",
ExpiredTitle: "Linket er udløbet",
InvalidTitle: "Afmeldingslinket er ugyldigt",
ReplyBody: "Svar i stedet på e-mailen, så holder afsenderen op med at skrive til dig.",
},
"de": {
TestTitle: "Dies war eine Test-E-Mail",
TestBody: "Test-E-Mails enthalten einen Link, der keiner Person zugeordnet ist. Es gibt daher nichts abzubestellen.",
ConfirmTitle: "Diese E-Mails abbestellen?",
ConfirmBody: "Nach Ihrer Bestätigung erhalten Sie keine weiteren E-Mails von diesem Absender.",
ConfirmButton: "Abbestellen",
DoneTitle: "Sie wurden abgemeldet",
DoneBody: "Sie erhalten keine weiteren E-Mails von diesem Absender.",
ResubscribeButton: "Versehentlich abgemeldet? Wieder anmelden",
ResubscribedTitle: "Sie sind wieder angemeldet",
ResubscribedBody: "Der Absender kann Ihnen wie bisher schreiben. Sie können sich über jede spätere E-Mail wieder abmelden.",
FailedTitle: "Dieser Link konnte nicht verarbeitet werden",
FailedBody: "Der Link ist nicht mehr gültig. Antworten Sie stattdessen auf die E-Mail, dann schreibt Ihnen der Absender nicht mehr.",
FailedResubscribeBody: "Der Link ist nicht mehr gültig. Antworten Sie auf die E-Mail, dann kann der Absender Sie wieder aufnehmen.",
RetryTitle: "Bitte versuchen Sie es gleich noch einmal",
RetryBody: "Wir konnten diesen Link gerade nicht prüfen. Öffnen Sie ihn in ein paar Minuten erneut oder antworten Sie auf die E-Mail, dann schreibt Ihnen der Absender nicht mehr.",
ExpiredTitle: "Dieser Link ist abgelaufen",
InvalidTitle: "Dieser Abmeldelink ist ungültig",
ReplyBody: "Antworten Sie stattdessen auf die E-Mail, dann schreibt Ihnen der Absender nicht mehr.",
},
"el": {
TestTitle: "Αυτό ήταν ένα δοκιμαστικό email",
TestBody: "Τα δοκιμαστικά email περιέχουν σύνδεσμο που δεν αντιστοιχεί σε κανέναν, οπότε δεν υπάρχει εγγραφή για κατάργηση.",
ConfirmTitle: "Κατάργηση εγγραφής από αυτά τα email;",
ConfirmBody: "Επιβεβαιώστε και δεν θα λαμβάνετε πλέον email από αυτόν τον αποστολέα.",
ConfirmButton: "Κατάργηση εγγραφής",
DoneTitle: "Η εγγραφή σας καταργήθηκε",
DoneBody: "Δεν θα λαμβάνετε πλέον email από αυτόν τον αποστολέα.",
ResubscribeButton: "Καταργήσατε την εγγραφή κατά λάθος; Εγγραφείτε ξανά",
ResubscribedTitle: "Η εγγραφή σας ενεργοποιήθηκε ξανά",
ResubscribedBody: "Ο αποστολέας μπορεί να σας στέλνει email όπως πριν. Μπορείτε να καταργήσετε την εγγραφή σας από οποιοδήποτε επόμενο email.",
FailedTitle: "Δεν ήταν δυνατή η επεξεργασία αυτού του συνδέσμου",
FailedBody: "Ο σύνδεσμος δεν ισχύει πλέον. Εναλλακτικά, απαντήστε στο email και ο αποστολέας θα σταματήσει να σας γράφει.",
FailedResubscribeBody: "Ο σύνδεσμος δεν ισχύει πλέον. Απαντήστε στο email και ο αποστολέας μπορεί να σας προσθέσει ξανά.",
RetryTitle: "Δοκιμάστε ξανά σε λίγο",
RetryBody: "Δεν ήταν δυνατός ο έλεγχος αυτού του συνδέσμου αυτή τη στιγμή. Ανοίξτε τον ξανά σε λίγα λεπτά ή απαντήστε στο email και ο αποστολέας θα σταματήσει να σας γράφει.",
ExpiredTitle: "Αυτός ο σύνδεσμος έχει λήξει",
InvalidTitle: "Αυτός ο σύνδεσμος κατάργησης εγγραφής δεν είναι έγκυρος",
ReplyBody: "Εναλλακτικά, απαντήστε στο email και ο αποστολέας θα σταματήσει να σας γράφει.",
},
"es": {
TestTitle: "Este era un correo de prueba",
TestBody: "Los envíos de prueba incluyen un enlace que no está vinculado a ninguna persona, así que no hay ninguna suscripción que cancelar.",
ConfirmTitle: "¿Desea darse de baja de estos correos?",
ConfirmBody: "Confirme y no recibirá más correos de este remitente.",
ConfirmButton: "Darme de baja",
DoneTitle: "Se ha dado de baja",
DoneBody: "No recibirá más correos de este remitente.",
ResubscribeButton: "¿Se dio de baja por error? Vuelva a suscribirse",
ResubscribedTitle: "Su suscripción se ha reactivado",
ResubscribedBody: "El remitente podrá escribirle como antes. Puede darse de baja desde cualquier correo posterior.",
FailedTitle: "No hemos podido procesar este enlace",
FailedBody: "El enlace ya no es válido. En su lugar, responda al correo y el remitente dejará de escribirle.",
FailedResubscribeBody: "El enlace ya no es válido. Responda al correo y el remitente podrá volver a incluirle.",
RetryTitle: "Vuelva a intentarlo en unos momentos",
RetryBody: "No hemos podido comprobar este enlace en este momento. Ábralo de nuevo en unos minutos o responda al correo y el remitente dejará de escribirle.",
ExpiredTitle: "Este enlace ha caducado",
InvalidTitle: "Este enlace para darse de baja no es válido",
ReplyBody: "En su lugar, responda al correo y el remitente dejará de escribirle.",
},
"et": {
TestTitle: "See oli testkiri",
TestBody: "Testkirjades on link, mis ei ole seotud ühegi inimesega, seega pole tellimust, millest loobuda.",
ConfirmTitle: "Kas loobuda nendest kirjadest?",
ConfirmBody: "Kinnitage ja te ei saa sellelt saatjalt enam kirju.",
ConfirmButton: "Loobu tellimusest",
DoneTitle: "Olete tellimusest loobunud",
DoneBody: "Te ei saa sellelt saatjalt enam kirju.",
ResubscribeButton: "Loobusite kogemata? Tellige uuesti",
ResubscribedTitle: "Tellimus on taastatud",
ResubscribedBody: "Saatja võib teile kirjutada nagu varem. Tellimusest saate loobuda iga hilisema kirja kaudu.",
FailedTitle: "Me ei saanud seda linki töödelda",
FailedBody: "Link ei kehti enam. Vastake selle asemel kirjale ja saatja lõpetab teile kirjutamise.",
FailedResubscribeBody: "Link ei kehti enam. Vastake kirjale ja saatja saab teid uuesti lisada.",
RetryTitle: "Proovige veidi aja pärast uuesti",
RetryBody: "Me ei saanud seda linki praegu kontrollida. Avage see mõne minuti pärast uuesti või vastake kirjale ja saatja lõpetab teile kirjutamise.",
ExpiredTitle: "Selle lingi kehtivus on lõppenud",
InvalidTitle: "See tellimusest loobumise link on kehtetu",
ReplyBody: "Vastake selle asemel kirjale ja saatja lõpetab teile kirjutamise.",
},
"fa": {
RTL: true,
TestTitle: "این یک ایمیل آزمایشی بود",
TestBody: "ایمیل‌های آزمایشی پیوندی دارند که به هیچ شخصی مربوط نیست، بنابراین اشتراکی برای لغو وجود ندارد.",
ConfirmTitle: "اشتراک این ایمیل‌ها لغو شود؟",
ConfirmBody: "با تأیید شما، دیگر ایمیلی از این فرستنده دریافت نخواهید کرد.",
ConfirmButton: "لغو اشتراک",
DoneTitle: "اشتراک شما لغو شد",
DoneBody: "دیگر ایمیلی از این فرستنده دریافت نخواهید کرد.",
ResubscribeButton: "اشتراک را اشتباهی لغو کردید؟ اشتراک دوباره",
ResubscribedTitle: "اشتراک شما دوباره فعال شد",
ResubscribedBody: "فرستنده می‌تواند مانند گذشته برای شما ایمیل بفرستد. می‌توانید اشتراک خود را از طریق هر ایمیل بعدی لغو کنید.",
FailedTitle: "نتوانستیم این پیوند را پردازش کنیم",
FailedBody: "این پیوند دیگر معتبر نیست. به‌جای آن به ایمیل پاسخ دهید تا فرستنده دیگر برای شما ایمیل نفرستد.",
FailedResubscribeBody: "این پیوند دیگر معتبر نیست. به ایمیل پاسخ دهید تا فرستنده بتواند دوباره شما را اضافه کند.",
RetryTitle: "کمی بعد دوباره امتحان کنید",
RetryBody: "در حال حاضر نتوانستیم این پیوند را بررسی کنیم. چند دقیقه دیگر دوباره آن را باز کنید، یا به ایمیل پاسخ دهید تا فرستنده دیگر برای شما ایمیل نفرستد.",
ExpiredTitle: "این پیوند منقضی شده است",
InvalidTitle: "این پیوند لغو اشتراک نامعتبر است",
ReplyBody: "به‌جای آن به ایمیل پاسخ دهید تا فرستنده دیگر برای شما ایمیل نفرستد.",
},
"fi": {
TestTitle: "Tämä oli testiviesti",
TestBody: "Testiviesteissä on linkki, jota ei ole liitetty kehenkään, joten peruttavaa tilausta ei ole.",
ConfirmTitle: "Perutaanko näiden sähköpostien tilaus?",
ConfirmBody: "Vahvista, niin et saa enää sähköposteja tältä lähettäjältä.",
ConfirmButton: "Peru tilaus",
DoneTitle: "Tilaus on peruttu",
DoneBody: "Et saa enää sähköposteja tältä lähettäjältä.",
ResubscribeButton: "Peruitko tilauksen vahingossa? Tilaa uudelleen",
ResubscribedTitle: "Tilaus on jälleen voimassa",
ResubscribedBody: "Lähettäjä voi lähettää sinulle sähköpostia kuten ennenkin. Voit perua tilauksen mistä tahansa myöhemmästä viestistä.",
FailedTitle: "Linkkiä ei voitu käsitellä",
FailedBody: "Linkki ei ole enää voimassa. Vastaa sen sijaan sähköpostiin, niin lähettäjä lopettaa viestien lähettämisen.",
FailedResubscribeBody: "Linkki ei ole enää voimassa. Vastaa sähköpostiin, niin lähettäjä voi lisätä sinut takaisin.",
RetryTitle: "Yritä hetken kuluttua uudelleen",
RetryBody: "Emme voineet tarkistaa linkkiä juuri nyt. Avaa se uudelleen muutaman minuutin kuluttua tai vastaa sähköpostiin, niin lähettäjä lopettaa viestien lähettämisen.",
ExpiredTitle: "Linkki on vanhentunut",
InvalidTitle: "Tilauksen peruutuslinkki ei ole kelvollinen",
ReplyBody: "Vastaa sen sijaan sähköpostiin, niin lähettäjä lopettaa viestien lähettämisen.",
},
"fil": {
TestTitle: "Test email lamang ito",
TestBody: "Ang mga test email ay may link na hindi nakatali sa sinuman, kaya walang kailangang i-unsubscribe.",
ConfirmTitle: "Mag-unsubscribe sa mga email na ito?",
ConfirmBody: "Kumpirmahin po ito at hindi na kayo makatatanggap ng anumang email mula sa nagpadalang ito.",
ConfirmButton: "Mag-unsubscribe",
DoneTitle: "Na-unsubscribe na kayo",
DoneBody: "Hindi na kayo makatatanggap ng anumang email mula sa nagpadalang ito.",
ResubscribeButton: "Nagkamali ng pag-unsubscribe? Mag-subscribe muli",
ResubscribedTitle: "Naka-subscribe na kayong muli",
ResubscribedBody: "Maaari na kayong padalhan muli ng email ng nagpadala gaya ng dati. Maaari kayong mag-unsubscribe sa alinmang susunod na email.",
FailedTitle: "Hindi namin maproseso ang link na iyon",
FailedBody: "Hindi na valid ang link. Sa halip, sagutin ang email at titigil na ang nagpadala sa pag-email sa inyo.",
FailedResubscribeBody: "Hindi na valid ang link. Sagutin ang email at maaari kayong idagdag muli ng nagpadala.",
RetryTitle: "Subukang muli maya-maya",
RetryBody: "Hindi namin masuri ang link na ito sa ngayon. Buksan itong muli pagkalipas ng ilang minuto, o sagutin ang email at titigil na ang nagpadala sa pag-email sa inyo.",
ExpiredTitle: "Nag-expire na ang link na ito",
InvalidTitle: "Hindi valid ang unsubscribe link na ito",
ReplyBody: "Sa halip, sagutin ang email at titigil na ang nagpadala sa pag-email sa inyo.",
},
"fr": {
TestTitle: "Ceci était un e-mail de test",
TestBody: "Les envois de test contiennent un lien qui n'est associé à aucun destinataire : il n'y a donc aucun abonnement à résilier.",
ConfirmTitle: "Se désabonner de ces e-mails ?",
ConfirmBody: "Confirmez et vous ne recevrez plus d'e-mails de cet expéditeur.",
ConfirmButton: "Se désabonner",
DoneTitle: "Votre désabonnement est confirmé",
DoneBody: "Vous ne recevrez plus d'e-mails de cet expéditeur.",
ResubscribeButton: "Désabonnement par erreur ? Se réabonner",
ResubscribedTitle: "Votre abonnement est rétabli",
ResubscribedBody: "L'expéditeur peut vous écrire comme avant. Vous pourrez vous désabonner depuis n'importe quel e-mail ultérieur.",
FailedTitle: "Nous n'avons pas pu traiter ce lien",
FailedBody: "Ce lien n'est plus valide. Répondez plutôt à l'e-mail et l'expéditeur cessera de vous écrire.",
FailedResubscribeBody: "Ce lien n'est plus valide. Répondez à l'e-mail et l'expéditeur pourra vous réinscrire.",
RetryTitle: "Réessayez dans quelques instants",
RetryBody: "Nous n'avons pas pu vérifier ce lien pour le moment. Ouvrez-le à nouveau dans quelques minutes, ou répondez à l'e-mail et l'expéditeur cessera de vous écrire.",
ExpiredTitle: "Ce lien a expiré",
InvalidTitle: "Ce lien de désabonnement n'est pas valide",
ReplyBody: "Répondez plutôt à l'e-mail et l'expéditeur cessera de vous écrire.",
},
"he": {
RTL: true,
TestTitle: "זו הייתה הודעת בדיקה",
TestBody: "הודעות בדיקה כוללות קישור שאינו משויך לאף אחד, ולכן אין הרשמה לבטל.",
ConfirmTitle: "לבטל את ההרשמה להודעות האלה?",
ConfirmBody: "לאחר האישור לא יישלחו אליך הודעות נוספות מהשולח הזה.",
ConfirmButton: "ביטול ההרשמה",
DoneTitle: "ההרשמה בוטלה",
DoneBody: "לא יישלחו אליך הודעות נוספות מהשולח הזה.",
ResubscribeButton: "ההרשמה בוטלה בטעות? הרשמה מחדש",
ResubscribedTitle: "ההרשמה חודשה",
ResubscribedBody: "השולח יכול לשלוח אליך הודעות כמו קודם. אפשר לבטל את ההרשמה מכל הודעה עתידית.",
FailedTitle: "לא הצלחנו לעבד את הקישור",
FailedBody: "הקישור כבר אינו בתוקף. במקום זאת, אפשר להשיב להודעה והשולח יפסיק לשלוח אליך הודעות.",
FailedResubscribeBody: "הקישור כבר אינו בתוקף. אפשר להשיב להודעה, והשולח יוכל להוסיף אותך מחדש.",
RetryTitle: "נא לנסות שוב בעוד כמה רגעים",
RetryBody: "לא הצלחנו לבדוק את הקישור כרגע. אפשר לפתוח אותו שוב בעוד כמה דקות, או להשיב להודעה והשולח יפסיק לשלוח אליך הודעות.",
ExpiredTitle: "תוקף הקישור פג",
InvalidTitle: "קישור ביטול ההרשמה אינו תקין",
ReplyBody: "במקום זאת, אפשר להשיב להודעה והשולח יפסיק לשלוח אליך הודעות.",
},
"hi": {
TestTitle: "यह एक परीक्षण ईमेल था",
TestBody: "परीक्षण के लिए भेजे गए ईमेल का लिंक किसी व्यक्ति से जुड़ा नहीं होता, इसलिए सदस्यता समाप्त करने के लिए कुछ भी नहीं है।",
ConfirmTitle: "इन ईमेल की सदस्यता समाप्त करें?",
ConfirmBody: "पुष्टि करें, इसके बाद आपको इस प्रेषक से और ईमेल नहीं मिलेंगे।",
ConfirmButton: "सदस्यता समाप्त करें",
DoneTitle: "आपकी सदस्यता समाप्त कर दी गई है",
DoneBody: "आपको इस प्रेषक से और ईमेल नहीं मिलेंगे।",
ResubscribeButton: "गलती से सदस्यता समाप्त हो गई? फिर से सदस्यता लें",
ResubscribedTitle: "आपकी सदस्यता फिर से शुरू हो गई है",
ResubscribedBody: "प्रेषक आपको पहले की तरह ईमेल भेज सकता है। आप बाद के किसी भी ईमेल से सदस्यता समाप्त कर सकते हैं।",
FailedTitle: "हम इस लिंक को संसाधित नहीं कर सके",
FailedBody: "यह लिंक अब मान्य नहीं है। इसके बजाय ईमेल का जवाब दें, तो प्रेषक आपको ईमेल भेजना बंद कर देगा।",
FailedResubscribeBody: "यह लिंक अब मान्य नहीं है। ईमेल का जवाब दें, तो प्रेषक आपको फिर से जोड़ सकता है।",
RetryTitle: "थोड़ी देर में फिर से प्रयास करें",
RetryBody: "हम अभी इस लिंक की जाँच नहीं कर सके। कुछ मिनट बाद इसे फिर से खोलें, या ईमेल का जवाब दें, तो प्रेषक आपको ईमेल भेजना बंद कर देगा।",
ExpiredTitle: "इस लिंक की समय-सीमा समाप्त हो गई है",
InvalidTitle: "सदस्यता समाप्त करने का यह लिंक अमान्य है",
ReplyBody: "इसके बजाय ईमेल का जवाब दें, तो प्रेषक आपको ईमेल भेजना बंद कर देगा।",
},
"hr": {
TestTitle: "Ovo je bila probna e-poruka",
TestBody: "Probna slanja sadrže poveznicu koja nije vezana ni uz koga, pa se nemate od čega odjaviti.",
ConfirmTitle: "Odjaviti se od ovih e-poruka?",
ConfirmBody: "Potvrdite i više nećete primati e-poruke od ovog pošiljatelja.",
ConfirmButton: "Odjavi se",
DoneTitle: "Odjavljeni ste",
DoneBody: "Više nećete primati e-poruke od ovog pošiljatelja.",
ResubscribeButton: "Odjavili ste se greškom? Ponovno se prijavite",
ResubscribedTitle: "Ponovno ste prijavljeni",
ResubscribedBody: "Pošiljatelj vam može slati e-poruke kao i prije. Možete se odjaviti putem bilo koje sljedeće e-poruke.",
FailedTitle: "Nismo mogli obraditi tu poveznicu",
FailedBody: "Poveznica više nije valjana. Umjesto toga odgovorite na e-poruku i pošiljatelj vam više neće pisati.",
FailedResubscribeBody: "Poveznica više nije valjana. Odgovorite na e-poruku i pošiljatelj vas može ponovno dodati.",
RetryTitle: "Pokušajte ponovno za koji trenutak",
RetryBody: "Trenutačno nismo mogli provjeriti ovu poveznicu. Otvorite je ponovno za nekoliko minuta ili odgovorite na e-poruku i pošiljatelj vam više neće pisati.",
ExpiredTitle: "Ova je poveznica istekla",
InvalidTitle: "Ova poveznica za odjavu nije valjana",
ReplyBody: "Umjesto toga odgovorite na e-poruku i pošiljatelj vam više neće pisati.",
},
"hu": {
TestTitle: "Ez egy teszt e-mail volt",
TestBody: "A tesztküldésekben lévő hivatkozás nem tartozik senkihez, így nincs miről leiratkozni.",
ConfirmTitle: "Leiratkozik ezekről az e-mailekről?",
ConfirmBody: "Erősítse meg, és ettől a feladótól nem kap több e-mailt.",
ConfirmButton: "Leiratkozás",
DoneTitle: "Sikeresen leiratkozott",
DoneBody: "Ettől a feladótól nem kap több e-mailt.",
ResubscribeButton: "Tévedésből iratkozott le? Újbóli feliratkozás",
ResubscribedTitle: "Újra feliratkozott",
ResubscribedBody: "A feladó ugyanúgy írhat Önnek, mint korábban. Bármelyik későbbi e-mailből leiratkozhat.",
FailedTitle: "Nem sikerült feldolgozni a hivatkozást",
FailedBody: "A hivatkozás már nem érvényes. Válaszoljon inkább az e-mailre, és a feladó nem ír Önnek többet.",
FailedResubscribeBody: "A hivatkozás már nem érvényes. Válaszoljon az e-mailre, és a feladó újra felveheti Önt.",
RetryTitle: "Próbálja újra kicsit később",
RetryBody: "Ezt a hivatkozást most nem tudtuk ellenőrizni. Nyissa meg újra néhány perc múlva, vagy válaszoljon az e-mailre, és a feladó nem ír Önnek többet.",
ExpiredTitle: "Ez a hivatkozás lejárt",
InvalidTitle: "Ez a leiratkozási hivatkozás érvénytelen",
ReplyBody: "Válaszoljon inkább az e-mailre, és a feladó nem ír Önnek többet.",
},
"id": {
TestTitle: "Ini adalah email uji coba",
TestBody: "Email uji coba berisi tautan yang tidak terkait dengan siapa pun, jadi tidak ada langganan yang perlu dihentikan.",
ConfirmTitle: "Berhenti berlangganan email ini?",
ConfirmBody: "Konfirmasikan, dan Anda tidak akan menerima email lagi dari pengirim ini.",
ConfirmButton: "Berhenti berlangganan",
DoneTitle: "Anda telah berhenti berlangganan",
DoneBody: "Anda tidak akan menerima email lagi dari pengirim ini.",
ResubscribeButton: "Tidak sengaja berhenti berlangganan? Berlangganan lagi",
ResubscribedTitle: "Anda berlangganan kembali",
ResubscribedBody: "Pengirim dapat mengirimi Anda email seperti sebelumnya. Anda dapat berhenti berlangganan melalui email mana pun berikutnya.",
FailedTitle: "Kami tidak dapat memproses tautan tersebut",
FailedBody: "Tautan ini sudah tidak berlaku. Sebagai gantinya, balas email tersebut dan pengirim akan berhenti mengirimi Anda email.",
FailedResubscribeBody: "Tautan ini sudah tidak berlaku. Balas email tersebut dan pengirim dapat menambahkan Anda kembali.",
RetryTitle: "Coba lagi sebentar lagi",
RetryBody: "Kami tidak dapat memeriksa tautan ini saat ini. Buka kembali dalam beberapa menit, atau balas email tersebut dan pengirim akan berhenti mengirimi Anda email.",
ExpiredTitle: "Tautan ini sudah kedaluwarsa",
InvalidTitle: "Tautan berhenti berlangganan ini tidak valid",
ReplyBody: "Sebagai gantinya, balas email tersebut dan pengirim akan berhenti mengirimi Anda email.",
},
"it": {
TestTitle: "Questa era un'email di prova",
TestBody: "Gli invii di prova contengono un link non associato a nessuno, quindi non c'è alcuna iscrizione da annullare.",
ConfirmTitle: "Annullare l'iscrizione a queste email?",
ConfirmBody: "Confermi e non riceverà più email da questo mittente.",
ConfirmButton: "Annulla iscrizione",
DoneTitle: "L'iscrizione è stata annullata",
DoneBody: "Non riceverà più email da questo mittente.",
ResubscribeButton: "Annullata per errore? Riattiva l'iscrizione",
ResubscribedTitle: "L'iscrizione è di nuovo attiva",
ResubscribedBody: "Il mittente potrà scriverle come prima. Può annullare l'iscrizione da qualsiasi email successiva.",
FailedTitle: "Non è stato possibile elaborare il link",
FailedBody: "Il link non è più valido. Risponda invece all'email e il mittente smetterà di scriverle.",
FailedResubscribeBody: "Il link non è più valido. Risponda all'email e il mittente potrà aggiungerla di nuovo.",
RetryTitle: "Riprovi tra poco",
RetryBody: "Al momento non è stato possibile verificare questo link. Lo riapra tra qualche minuto, oppure risponda all'email e il mittente smetterà di scriverle.",
ExpiredTitle: "Questo link è scaduto",
InvalidTitle: "Questo link di annullamento dell'iscrizione non è valido",
ReplyBody: "Risponda invece all'email e il mittente smetterà di scriverle.",
},
"ja": {
TestTitle: "これはテストメールです",
TestBody: "テスト送信のリンクは特定の宛先に紐づいていないため、配信停止の対象はありません。",
ConfirmTitle: "これらのメールの配信を停止しますか?",
ConfirmBody: "確定すると、この送信者からのメールは今後届かなくなります。",
ConfirmButton: "配信を停止する",
DoneTitle: "配信を停止しました",
DoneBody: "この送信者からのメールは今後届きません。",
ResubscribeButton: "誤って停止しましたか?配信を再開する",
ResubscribedTitle: "配信を再開しました",
ResubscribedBody: "送信者はこれまでどおりメールを送信できます。今後届くメールからいつでも配信を停止できます。",
FailedTitle: "このリンクを処理できませんでした",
FailedBody: "このリンクは無効になっています。代わりにメールへ返信していただければ、送信者は送信を停止します。",
FailedResubscribeBody: "このリンクは無効になっています。メールへ返信していただければ、送信者が配信を再開できます。",
RetryTitle: "しばらくしてからもう一度お試しください",
RetryBody: "現在このリンクを確認できませんでした。数分後にもう一度開くか、メールへ返信していただければ、送信者は送信を停止します。",
ExpiredTitle: "このリンクは有効期限が切れています",
InvalidTitle: "この配信停止リンクは無効です",
ReplyBody: "代わりにメールへ返信していただければ、送信者は送信を停止します。",
},
"ko": {
TestTitle: "테스트 이메일이었습니다",
TestBody: "테스트 발송에 포함된 링크는 특정 수신자와 연결되어 있지 않으므로 수신 거부할 대상이 없습니다.",
ConfirmTitle: "이 이메일 수신을 거부하시겠습니까?",
ConfirmBody: "확인하시면 이 발신자로부터 더 이상 이메일을 받지 않게 됩니다.",
ConfirmButton: "수신 거부",
DoneTitle: "수신 거부되었습니다",
DoneBody: "이 발신자로부터 더 이상 이메일을 받지 않게 됩니다.",
ResubscribeButton: "실수로 수신 거부하셨나요? 다시 구독하기",
ResubscribedTitle: "다시 구독되었습니다",
ResubscribedBody: "발신자가 이전처럼 이메일을 보낼 수 있습니다. 이후 받는 이메일에서 언제든지 수신을 거부하실 수 있습니다.",
FailedTitle: "링크를 처리할 수 없습니다",
FailedBody: "이 링크는 더 이상 유효하지 않습니다. 대신 이메일에 회신하시면 발신자가 발송을 중단합니다.",
FailedResubscribeBody: "이 링크는 더 이상 유효하지 않습니다. 이메일에 회신하시면 발신자가 다시 추가해 드릴 수 있습니다.",
RetryTitle: "잠시 후 다시 시도해 주세요",
RetryBody: "지금은 이 링크를 확인할 수 없습니다. 몇 분 후 다시 열어 보시거나, 이메일에 회신하시면 발신자가 발송을 중단합니다.",
ExpiredTitle: "만료된 링크입니다",
InvalidTitle: "유효하지 않은 수신 거부 링크입니다",
ReplyBody: "대신 이메일에 회신하시면 발신자가 발송을 중단합니다.",
},
"lt": {
TestTitle: "Tai buvo bandomasis el. laiškas",
TestBody: "Bandomuosiuose laiškuose esanti nuoroda nesusieta su jokiu gavėju, todėl nėra ko atsisakyti.",
ConfirmTitle: "Atsisakyti šių el. laiškų?",
ConfirmBody: "Patvirtinkite ir daugiau nebegausite el. laiškų iš šio siuntėjo.",
ConfirmButton: "Atsisakyti prenumeratos",
DoneTitle: "Prenumeratos atsisakyta",
DoneBody: "Daugiau nebegausite el. laiškų iš šio siuntėjo.",
ResubscribeButton: "Atsisakėte per klaidą? Užsiprenumeruoti iš naujo",
ResubscribedTitle: "Prenumerata atnaujinta",
ResubscribedBody: "Siuntėjas vėl galės jums rašyti kaip anksčiau. Prenumeratos galite atsisakyti per bet kurį vėlesnį el. laišką.",
FailedTitle: "Nepavyko apdoroti šios nuorodos",
FailedBody: "Ši nuoroda nebegalioja. Vietoj to atsakykite į el. laišką ir siuntėjas jums nebesiųs laiškų.",
FailedResubscribeBody: "Ši nuoroda nebegalioja. Atsakykite į el. laišką ir siuntėjas galės jus vėl įtraukti.",
RetryTitle: "Netrukus bandykite dar kartą",
RetryBody: "Šiuo metu nepavyko patikrinti šios nuorodos. Atidarykite ją dar kartą po kelių minučių arba atsakykite į el. laišką ir siuntėjas jums nebesiųs laiškų.",
ExpiredTitle: "Šios nuorodos galiojimas baigėsi",
InvalidTitle: "Ši prenumeratos atsisakymo nuoroda negalioja",
ReplyBody: "Vietoj to atsakykite į el. laišką ir siuntėjas jums nebesiųs laiškų.",
},
"lv": {
TestTitle: "Šis bija testa e-pasts",
TestBody: "Testa sūtījumos ir saite, kas nav piesaistīta nevienam adresātam, tāpēc nav no kā atteikties.",
ConfirmTitle: "Atteikties no šiem e-pastiem?",
ConfirmBody: "Apstipriniet, un jūs vairs nesaņemsiet e-pastus no šī sūtītāja.",
ConfirmButton: "Atteikties",
DoneTitle: "Jūsu abonements ir atcelts",
DoneBody: "Jūs vairs nesaņemsiet e-pastus no šī sūtītāja.",
ResubscribeButton: "Atteicāties kļūdas pēc? Abonēt atkārtoti",
ResubscribedTitle: "Jūsu abonements ir atjaunots",
ResubscribedBody: "Sūtītājs var jums rakstīt tāpat kā iepriekš. No abonementa varat atteikties jebkurā nākamajā e-pastā.",
FailedTitle: "Šo saiti neizdevās apstrādāt",
FailedBody: "Saite vairs nav derīga. Tā vietā atbildiet uz e-pastu, un sūtītājs jums vairs nerakstīs.",
FailedResubscribeBody: "Saite vairs nav derīga. Atbildiet uz e-pastu, un sūtītājs varēs jūs pievienot atkārtoti.",
RetryTitle: "Mēģiniet vēlreiz nedaudz vēlāk",
RetryBody: "Pašlaik šo saiti neizdevās pārbaudīt. Atveriet to vēlreiz pēc dažām minūtēm vai atbildiet uz e-pastu, un sūtītājs jums vairs nerakstīs.",
ExpiredTitle: "Šīs saites derīguma termiņš ir beidzies",
InvalidTitle: "Šī atteikšanās saite nav derīga",
ReplyBody: "Tā vietā atbildiet uz e-pastu, un sūtītājs jums vairs nerakstīs.",
},
"ms": {
TestTitle: "Ini ialah e-mel ujian",
TestBody: "E-mel ujian mengandungi pautan yang tidak dikaitkan dengan sesiapa, jadi tiada langganan untuk dihentikan.",
ConfirmTitle: "Berhenti melanggan e-mel ini?",
ConfirmBody: "Sahkan dan anda tidak akan menerima e-mel lagi daripada pengirim ini.",
ConfirmButton: "Berhenti melanggan",
DoneTitle: "Anda telah berhenti melanggan",
DoneBody: "Anda tidak akan menerima e-mel lagi daripada pengirim ini.",
ResubscribeButton: "Berhenti melanggan secara tidak sengaja? Langgan semula",
ResubscribedTitle: "Anda telah melanggan semula",
ResubscribedBody: "Pengirim boleh menghantar e-mel kepada anda seperti sebelum ini. Anda boleh berhenti melanggan melalui mana-mana e-mel seterusnya.",
FailedTitle: "Kami tidak dapat memproses pautan itu",
FailedBody: "Pautan ini tidak lagi sah. Sebaliknya, balas e-mel tersebut dan pengirim akan berhenti menghantar e-mel kepada anda.",
FailedResubscribeBody: "Pautan ini tidak lagi sah. Balas e-mel tersebut dan pengirim boleh menambah anda semula.",
RetryTitle: "Cuba lagi sebentar nanti",
RetryBody: "Kami tidak dapat menyemak pautan ini sekarang. Buka semula dalam beberapa minit, atau balas e-mel tersebut dan pengirim akan berhenti menghantar e-mel kepada anda.",
ExpiredTitle: "Pautan ini telah tamat tempoh",
InvalidTitle: "Pautan berhenti melanggan ini tidak sah",
ReplyBody: "Sebaliknya, balas e-mel tersebut dan pengirim akan berhenti menghantar e-mel kepada anda.",
},
"nb": {
TestTitle: "Dette var en test-e-post",
TestBody: "Testutsendelser har en lenke som ikke er knyttet til noen, så det er ingenting å melde seg av.",
ConfirmTitle: "Vil du melde deg av disse e-postene?",
ConfirmBody: "Bekreft, så får du ikke flere e-poster fra denne avsenderen.",
ConfirmButton: "Meld av",
DoneTitle: "Du er meldt av",
DoneBody: "Du får ikke flere e-poster fra denne avsenderen.",
ResubscribeButton: "Meldt av ved en feil? Meld deg på igjen",
ResubscribedTitle: "Du er påmeldt igjen",
ResubscribedBody: "Avsenderen kan sende deg e-post som før. Du kan melde deg av fra hvilken som helst senere e-post.",
FailedTitle: "Vi kunne ikke behandle lenken",
FailedBody: "Lenken er ikke lenger gyldig. Svar på e-posten i stedet, så slutter avsenderen å sende deg e-post.",
FailedResubscribeBody: "Lenken er ikke lenger gyldig. Svar på e-posten, så kan avsenderen legge deg til igjen.",
RetryTitle: "Prøv igjen om litt",
RetryBody: "Vi kunne ikke sjekke denne lenken akkurat nå. Åpne den igjen om noen minutter, eller svar på e-posten, så slutter avsenderen å sende deg e-post.",
ExpiredTitle: "Denne lenken har utløpt",
InvalidTitle: "Denne avmeldingslenken er ugyldig",
ReplyBody: "Svar på e-posten i stedet, så slutter avsenderen å sende deg e-post.",
},
"nl": {
TestTitle: "Dit was een testmail",
TestBody: "Testverzendingen bevatten een link die aan niemand gekoppeld is, dus er is niets om u voor af te melden.",
ConfirmTitle: "Afmelden voor deze e-mails?",
ConfirmBody: "Bevestig dit en u ontvangt geen e-mails meer van deze afzender.",
ConfirmButton: "Afmelden",
DoneTitle: "U bent afgemeld",
DoneBody: "U ontvangt geen e-mails meer van deze afzender.",
ResubscribeButton: "Per ongeluk afgemeld? Opnieuw aanmelden",
ResubscribedTitle: "U bent weer aangemeld",
ResubscribedBody: "De afzender kan u weer e-mailen zoals voorheen. U kunt zich via elke volgende e-mail afmelden.",
FailedTitle: "We konden deze link niet verwerken",
FailedBody: "De link is niet meer geldig. Beantwoord in plaats daarvan de e-mail, dan mailt de afzender u niet meer.",
FailedResubscribeBody: "De link is niet meer geldig. Beantwoord de e-mail, dan kan de afzender u weer toevoegen.",
RetryTitle: "Probeer het zo opnieuw",
RetryBody: "We konden deze link nu niet controleren. Open hem over een paar minuten opnieuw, of beantwoord de e-mail, dan mailt de afzender u niet meer.",
ExpiredTitle: "Deze link is verlopen",
InvalidTitle: "Deze afmeldlink is ongeldig",
ReplyBody: "Beantwoord in plaats daarvan de e-mail, dan mailt de afzender u niet meer.",
},
"pl": {
TestTitle: "To była wiadomość testowa",
TestBody: "Wiadomości testowe zawierają link, który nie jest przypisany do żadnego odbiorcy, więc nie ma z czego się wypisać.",
ConfirmTitle: "Wypisać się z tych wiadomości?",
ConfirmBody: "Po potwierdzeniu wiadomości od tego nadawcy przestaną przychodzić.",
ConfirmButton: "Wypisz się",
DoneTitle: "Wypisanie potwierdzone",
DoneBody: "Wiadomości od tego nadawcy nie będą już przychodzić.",
ResubscribeButton: "Wypisanie przez pomyłkę? Zapisz się ponownie",
ResubscribedTitle: "Zapis został przywrócony",
ResubscribedBody: "Nadawca może znów wysyłać wiadomości tak jak wcześniej. Z każdej kolejnej wiadomości można się wypisać.",
FailedTitle: "Nie udało się przetworzyć tego linku",
FailedBody: "Ten link jest już nieważny. Zamiast tego wystarczy odpowiedzieć na wiadomość, a nadawca przestanie pisać.",
FailedResubscribeBody: "Ten link jest już nieważny. Wystarczy odpowiedzieć na wiadomość, a nadawca będzie mógł ponownie dodać ten adres.",
RetryTitle: "Prosimy spróbować ponownie za chwilę",
RetryBody: "Nie udało się teraz sprawdzić tego linku. Prosimy otworzyć go ponownie za kilka minut lub odpowiedzieć na wiadomość, a nadawca przestanie pisać.",
ExpiredTitle: "Ten link wygasł",
InvalidTitle: "Ten link do wypisania jest nieprawidłowy",
ReplyBody: "Zamiast tego wystarczy odpowiedzieć na wiadomość, a nadawca przestanie pisać.",
},
"pt": {
TestTitle: "Este foi um e-mail de teste",
TestBody: "Os envios de teste trazem um link que não está vinculado a ninguém, portanto não há inscrição para cancelar.",
ConfirmTitle: "Cancelar a inscrição nestes e-mails?",
ConfirmBody: "Confirme e não receberá mais e-mails deste remetente.",
ConfirmButton: "Cancelar inscrição",
DoneTitle: "Inscrição cancelada",
DoneBody: "Não receberá mais e-mails deste remetente.",
ResubscribeButton: "Cancelou por engano? Inscrever-se novamente",
ResubscribedTitle: "Inscrição reativada",
ResubscribedBody: "O remetente pode enviar e-mails como antes. É possível cancelar a inscrição em qualquer e-mail futuro.",
FailedTitle: "Não foi possível processar este link",
FailedBody: "Este link não é mais válido. Em vez disso, responda ao e-mail e o remetente deixará de enviar mensagens.",
FailedResubscribeBody: "Este link não é mais válido. Responda ao e-mail e o remetente poderá reativar a inscrição.",
RetryTitle: "Tente novamente em instantes",
RetryBody: "Não foi possível verificar este link agora. Abra-o novamente em alguns minutos ou responda ao e-mail e o remetente deixará de enviar mensagens.",
ExpiredTitle: "Este link expirou",
InvalidTitle: "Este link de cancelamento é inválido",
ReplyBody: "Em vez disso, responda ao e-mail e o remetente deixará de enviar mensagens.",
},
"ro": {
TestTitle: "Acesta a fost un e-mail de test",
TestBody: "Trimiterile de test conțin un link care nu este asociat cu nicio persoană, așa că nu aveți de la ce să vă dezabonați.",
ConfirmTitle: "Vă dezabonați de la aceste e-mailuri?",
ConfirmBody: "Confirmați și nu veți mai primi e-mailuri de la acest expeditor.",
ConfirmButton: "Dezabonare",
DoneTitle: "V-ați dezabonat",
DoneBody: "Nu veți mai primi e-mailuri de la acest expeditor.",
ResubscribeButton: "V-ați dezabonat din greșeală? Reabonare",
ResubscribedTitle: "V-ați abonat din nou",
ResubscribedBody: "Expeditorul vă poate trimite e-mailuri ca înainte. Vă puteți dezabona din orice e-mail ulterior.",
FailedTitle: "Nu am putut procesa acest link",
FailedBody: "Linkul nu mai este valabil. Răspundeți în schimb la e-mail, iar expeditorul nu vă va mai scrie.",
FailedResubscribeBody: "Linkul nu mai este valabil. Răspundeți la e-mail, iar expeditorul vă poate adăuga din nou.",
RetryTitle: "Încercați din nou în curând",
RetryBody: "Nu am putut verifica acest link acum. Deschideți-l din nou peste câteva minute sau răspundeți la e-mail, iar expeditorul nu vă va mai scrie.",
ExpiredTitle: "Acest link a expirat",
InvalidTitle: "Acest link de dezabonare nu este valid",
ReplyBody: "Răspundeți în schimb la e-mail, iar expeditorul nu vă va mai scrie.",
},
"ru": {
TestTitle: "Это было тестовое письмо",
TestBody: "Тестовые письма содержат ссылку, не привязанную ни к одному получателю, поэтому отписываться не от чего.",
ConfirmTitle: "Отписаться от этих писем?",
ConfirmBody: "Подтвердите, и вы больше не будете получать письма от этого отправителя.",
ConfirmButton: "Отписаться",
DoneTitle: "Вы отписались",
DoneBody: "Вы больше не будете получать письма от этого отправителя.",
ResubscribeButton: "Отписались по ошибке? Подписаться снова",
ResubscribedTitle: "Вы снова подписаны",
ResubscribedBody: "Отправитель может писать вам, как и раньше. Вы можете отписаться в любом из следующих писем.",
FailedTitle: "Не удалось обработать ссылку",
FailedBody: "Ссылка больше не действительна. Вместо этого ответьте на письмо, и отправитель перестанет вам писать.",
FailedResubscribeBody: "Ссылка больше не действительна. Ответьте на письмо, и отправитель сможет снова добавить вас.",
RetryTitle: "Попробуйте чуть позже",
RetryBody: "Сейчас не удалось проверить эту ссылку. Откройте её снова через несколько минут или ответьте на письмо, и отправитель перестанет вам писать.",
ExpiredTitle: "Срок действия ссылки истёк",
InvalidTitle: "Эта ссылка для отписки недействительна",
ReplyBody: "Вместо этого ответьте на письмо, и отправитель перестанет вам писать.",
},
"sk": {
TestTitle: "Toto bol testovací e-mail",
TestBody: "Testovacie e-maily obsahujú odkaz, ktorý nie je priradený žiadnemu príjemcovi, preto sa nie je z čoho odhlásiť.",
ConfirmTitle: "Odhlásiť sa z odberu týchto e-mailov?",
ConfirmBody: "Po potvrdení už od tohto odosielateľa nebudete dostávať ďalšie e-maily.",
ConfirmButton: "Odhlásiť sa",
DoneTitle: "Odber bol zrušený",
DoneBody: "Od tohto odosielateľa už nebudete dostávať ďalšie e-maily.",
ResubscribeButton: "Odhlásili ste sa omylom? Prihlásiť sa znova",
ResubscribedTitle: "Odber je znova aktívny",
ResubscribedBody: "Odosielateľ vám môže písať ako predtým. Z odberu sa môžete odhlásiť v ktoromkoľvek ďalšom e-maile.",
FailedTitle: "Tento odkaz sa nepodarilo spracovať",
FailedBody: "Odkaz už nie je platný. Namiesto toho odpovedzte na e-mail a odosielateľ vám prestane písať.",
FailedResubscribeBody: "Odkaz už nie je platný. Odpovedzte na e-mail a odosielateľ vás môže znova pridať.",
RetryTitle: "Skúste to o chvíľu znova",
RetryBody: "Tento odkaz sa teraz nepodarilo overiť. Otvorte ho znova o niekoľko minút alebo odpovedzte na e-mail a odosielateľ vám prestane písať.",
ExpiredTitle: "Platnosť tohto odkazu vypršala",
InvalidTitle: "Tento odkaz na odhlásenie je neplatný",
ReplyBody: "Namiesto toho odpovedzte na e-mail a odosielateľ vám prestane písať.",
},
"sl": {
TestTitle: "To je bilo testno e-sporočilo",
TestBody: "Testna sporočila vsebujejo povezavo, ki ni vezana na nobenega prejemnika, zato se nimate od česa odjaviti.",
ConfirmTitle: "Se želite odjaviti od teh e-sporočil?",
ConfirmBody: "Potrdite in od tega pošiljatelja ne boste več prejemali e-sporočil.",
ConfirmButton: "Odjavi me",
DoneTitle: "Odjava je bila uspešna",
DoneBody: "Od tega pošiljatelja ne boste več prejemali e-sporočil.",
ResubscribeButton: "Ste se odjavili pomotoma? Ponovno se prijavite",
ResubscribedTitle: "Ponovno ste prijavljeni",
ResubscribedBody: "Pošiljatelj vam lahko piše kot prej. Odjavite se lahko iz katerega koli poznejšega e-sporočila.",
FailedTitle: "Povezave ni bilo mogoče obdelati",
FailedBody: "Povezava ni več veljavna. Namesto tega odgovorite na e-sporočilo in pošiljatelj vam ne bo več pisal.",
FailedResubscribeBody: "Povezava ni več veljavna. Odgovorite na e-sporočilo in pošiljatelj vas lahko znova doda.",
RetryTitle: "Poskusite znova čez nekaj trenutkov",
RetryBody: "Te povezave trenutno ni bilo mogoče preveriti. Znova jo odprite čez nekaj minut ali odgovorite na e-sporočilo in pošiljatelj vam ne bo več pisal.",
ExpiredTitle: "Ta povezava je potekla",
InvalidTitle: "Ta povezava za odjavo ni veljavna",
ReplyBody: "Namesto tega odgovorite na e-sporočilo in pošiljatelj vam ne bo več pisal.",
},
"sr": {
TestTitle: "Ово је била пробна порука",
TestBody: "Пробне поруке садрже линк који није везан ни за једног примаоца, па нема од чега да се одјавите.",
ConfirmTitle: "Желите ли да се одјавите од ових порука?",
ConfirmBody: "Потврдите и више нећете примати поруке од овог пошиљаоца.",
ConfirmButton: "Одјави ме",
DoneTitle: "Одјављени сте",
DoneBody: "Више нећете примати поруке од овог пошиљаоца.",
ResubscribeButton: "Одјавили сте се грешком? Поново се пријавите",
ResubscribedTitle: "Поново сте пријављени",
ResubscribedBody: "Пошиљалац вам може писати као и раније. Можете се одјавити из било које наредне поруке.",
FailedTitle: "Нисмо могли да обрадимо овај линк",
FailedBody: "Линк више није важећи. Уместо тога одговорите на поруку и пошиљалац ће престати да вам пише.",
FailedResubscribeBody: "Линк више није важећи. Одговорите на поруку и пошиљалац вас може поново додати.",
RetryTitle: "Покушајте поново ускоро",
RetryBody: "Тренутно нисмо могли да проверимо овај линк. Отворите га поново за неколико минута или одговорите на поруку и пошиљалац ће престати да вам пише.",
ExpiredTitle: "Овај линк је истекао",
InvalidTitle: "Овај линк за одјаву није важећи",
ReplyBody: "Уместо тога одговорите на поруку и пошиљалац ће престати да вам пише.",
},
"sv": {
TestTitle: "Det här var ett testmejl",
TestBody: "Testutskick innehåller en länk som inte är kopplad till någon mottagare, så det finns ingen prenumeration att avsluta.",
ConfirmTitle: "Avsluta prenumerationen på de här mejlen?",
ConfirmBody: "Bekräfta så får du inga fler mejl från den här avsändaren.",
ConfirmButton: "Avsluta prenumeration",
DoneTitle: "Din prenumeration är avslutad",
DoneBody: "Du får inga fler mejl från den här avsändaren.",
ResubscribeButton: "Avslutade du av misstag? Prenumerera igen",
ResubscribedTitle: "Du prenumererar igen",
ResubscribedBody: "Avsändaren kan mejla dig som tidigare. Du kan avsluta prenumerationen från vilket senare mejl som helst.",
FailedTitle: "Vi kunde inte hantera länken",
FailedBody: "Länken är inte längre giltig. Svara på mejlet i stället så slutar avsändaren att skriva till dig.",
FailedResubscribeBody: "Länken är inte längre giltig. Svara på mejlet så kan avsändaren lägga till dig igen.",
RetryTitle: "Försök igen om en stund",
RetryBody: "Vi kunde inte kontrollera länken just nu. Öppna den igen om några minuter, eller svara på mejlet så slutar avsändaren att skriva till dig.",
ExpiredTitle: "Länken har gått ut",
InvalidTitle: "Länken för att avsluta prenumerationen är ogiltig",
ReplyBody: "Svara på mejlet i stället så slutar avsändaren att skriva till dig.",
},
"sw": {
TestTitle: "Hii ilikuwa barua pepe ya majaribio",
TestBody: "Barua pepe za majaribio zina kiungo kisichohusishwa na mtu yeyote, kwa hiyo hakuna cha kujiondoa.",
ConfirmTitle: "Ungependa kujiondoa kwenye barua pepe hizi?",
ConfirmBody: "Thibitisha na hutapokea tena barua pepe kutoka kwa mtumaji huyu.",
ConfirmButton: "Jiondoe",
DoneTitle: "Umejiondoa",
DoneBody: "Hutapokea tena barua pepe kutoka kwa mtumaji huyu.",
ResubscribeButton: "Ulijiondoa kimakosa? Jisajili tena",
ResubscribedTitle: "Umejisajili tena",
ResubscribedBody: "Mtumaji anaweza kukutumia barua pepe kama awali. Unaweza kujiondoa kupitia barua pepe yoyote itakayofuata.",
FailedTitle: "Hatukuweza kushughulikia kiungo hicho",
FailedBody: "Kiungo hiki si halali tena. Badala yake, jibu barua pepe hiyo na mtumaji ataacha kukutumia barua.",
FailedResubscribeBody: "Kiungo hiki si halali tena. Jibu barua pepe hiyo na mtumaji anaweza kukuongeza tena.",
RetryTitle: "Jaribu tena baada ya muda mfupi",
RetryBody: "Hatukuweza kukagua kiungo hiki kwa sasa. Kifungue tena baada ya dakika chache, au jibu barua pepe hiyo na mtumaji ataacha kukutumia barua.",
ExpiredTitle: "Muda wa kiungo hiki umeisha",
InvalidTitle: "Kiungo hiki cha kujiondoa si halali",
ReplyBody: "Badala yake, jibu barua pepe hiyo na mtumaji ataacha kukutumia barua.",
},
"ta": {
TestTitle: "இது ஒரு சோதனை மின்னஞ்சல்",
TestBody: "சோதனை மின்னஞ்சல்களில் உள்ள இணைப்பு யாருடனும் இணைக்கப்படவில்லை, எனவே குழுவிலக எதுவும் இல்லை.",
ConfirmTitle: "இந்த மின்னஞ்சல்களிலிருந்து குழுவிலக வேண்டுமா?",
ConfirmBody: "உறுதிசெய்தால், இந்த அனுப்புநரிடமிருந்து இனி உங்களுக்கு மின்னஞ்சல்கள் வராது.",
ConfirmButton: "குழுவிலகு",
DoneTitle: "நீங்கள் குழுவிலகிவிட்டீர்கள்",
DoneBody: "இந்த அனுப்புநரிடமிருந்து இனி உங்களுக்கு மின்னஞ்சல்கள் வராது.",
ResubscribeButton: "தவறுதலாகக் குழுவிலகினீர்களா? மீண்டும் குழுசேருங்கள்",
ResubscribedTitle: "நீங்கள் மீண்டும் குழுசேர்ந்துள்ளீர்கள்",
ResubscribedBody: "அனுப்புநர் முன்பு போலவே உங்களுக்கு மின்னஞ்சல் அனுப்பலாம். பின்னர் வரும் எந்த மின்னஞ்சலிலிருந்தும் நீங்கள் குழுவிலகலாம்.",
FailedTitle: "அந்த இணைப்பைச் செயலாக்க முடியவில்லை",
FailedBody: "இந்த இணைப்பு இனி செல்லுபடியாகாது. அதற்குப் பதிலாக மின்னஞ்சலுக்குப் பதிலளியுங்கள், அனுப்புநர் உங்களுக்கு அனுப்புவதை நிறுத்துவார்.",
FailedResubscribeBody: "இந்த இணைப்பு இனி செல்லுபடியாகாது. மின்னஞ்சலுக்குப் பதிலளியுங்கள், அனுப்புநர் உங்களை மீண்டும் சேர்க்கலாம்.",
RetryTitle: "சிறிது நேரத்தில் மீண்டும் முயலுங்கள்",
RetryBody: "இப்போது இந்த இணைப்பைச் சரிபார்க்க முடியவில்லை. சில நிமிடங்களில் மீண்டும் திறக்கவும், அல்லது மின்னஞ்சலுக்குப் பதிலளியுங்கள், அனுப்புநர் உங்களுக்கு அனுப்புவதை நிறுத்துவார்.",
ExpiredTitle: "இந்த இணைப்பு காலாவதியாகிவிட்டது",
InvalidTitle: "இந்தக் குழுவிலகல் இணைப்பு செல்லாது",
ReplyBody: "அதற்குப் பதிலாக மின்னஞ்சலுக்குப் பதிலளியுங்கள், அனுப்புநர் உங்களுக்கு அனுப்புவதை நிறுத்துவார்.",
},
"th": {
TestTitle: "นี่เป็นอีเมลทดสอบ",
TestBody: "อีเมลทดสอบมีลิงก์ที่ไม่ได้ผูกกับผู้รับรายใด จึงไม่มีการรับอีเมลที่ต้องยกเลิก",
ConfirmTitle: "ต้องการยกเลิกการรับอีเมลเหล่านี้หรือไม่",
ConfirmBody: "เมื่อยืนยันแล้ว คุณจะไม่ได้รับอีเมลจากผู้ส่งรายนี้อีก",
ConfirmButton: "ยกเลิกการรับอีเมล",
DoneTitle: "ยกเลิกการรับอีเมลแล้ว",
DoneBody: "คุณจะไม่ได้รับอีเมลจากผู้ส่งรายนี้อีก",
ResubscribeButton: "ยกเลิกโดยไม่ได้ตั้งใจใช่ไหม สมัครรับอีกครั้ง",
ResubscribedTitle: "คุณสมัครรับอีเมลอีกครั้งแล้ว",
ResubscribedBody: "ผู้ส่งสามารถส่งอีเมลถึงคุณได้เหมือนเดิม คุณยกเลิกการรับได้จากอีเมลฉบับใดก็ได้ในภายหลัง",
FailedTitle: "ไม่สามารถดำเนินการกับลิงก์นี้ได้",
FailedBody: "ลิงก์นี้ใช้งานไม่ได้แล้ว โปรดตอบกลับอีเมลแทน แล้วผู้ส่งจะหยุดส่งอีเมลถึงคุณ",
FailedResubscribeBody: "ลิงก์นี้ใช้งานไม่ได้แล้ว โปรดตอบกลับอีเมล แล้วผู้ส่งจะเพิ่มคุณกลับเข้าไปได้",
RetryTitle: "โปรดลองอีกครั้งในอีกสักครู่",
RetryBody: "ขณะนี้ไม่สามารถตรวจสอบลิงก์นี้ได้ โปรดเปิดอีกครั้งในอีกไม่กี่นาที หรือตอบกลับอีเมล แล้วผู้ส่งจะหยุดส่งอีเมลถึงคุณ",
ExpiredTitle: "ลิงก์นี้หมดอายุแล้ว",
InvalidTitle: "ลิงก์ยกเลิกการรับอีเมลนี้ไม่ถูกต้อง",
ReplyBody: "โปรดตอบกลับอีเมลแทน แล้วผู้ส่งจะหยุดส่งอีเมลถึงคุณ",
},
"tr": {
TestTitle: "Bu bir test e-postasıydı",
TestBody: "Test gönderimlerindeki bağlantı hiçbir alıcıya bağlı değildir, bu nedenle abonelikten çıkılacak bir şey yok.",
ConfirmTitle: "Bu e-postaların aboneliğinden çıkılsın mı?",
ConfirmBody: "Onayladığınızda bu göndericiden başka e-posta almayacaksınız.",
ConfirmButton: "Abonelikten çık",
DoneTitle: "Abonelikten çıktınız",
DoneBody: "Bu göndericiden başka e-posta almayacaksınız.",
ResubscribeButton: "Yanlışlıkla mı çıktınız? Yeniden abone olun",
ResubscribedTitle: "Yeniden abone oldunuz",
ResubscribedBody: "Gönderici size eskisi gibi e-posta gönderebilir. Sonraki herhangi bir e-postadan abonelikten çıkabilirsiniz.",
FailedTitle: "Bu bağlantıyı işleyemedik",
FailedBody: "Bağlantı artık geçerli değil. Bunun yerine e-postayı yanıtlayın, gönderici size yazmayı bırakacaktır.",
FailedResubscribeBody: "Bağlantı artık geçerli değil. E-postayı yanıtlayın, gönderici sizi yeniden ekleyebilir.",
RetryTitle: "Birazdan tekrar deneyin",
RetryBody: "Bu bağlantıyı şu anda kontrol edemedik. Birkaç dakika sonra yeniden açın veya e-postayı yanıtlayın, gönderici size yazmayı bırakacaktır.",
ExpiredTitle: "Bu bağlantının süresi doldu",
InvalidTitle: "Bu abonelikten çıkma bağlantısı geçersiz",
ReplyBody: "Bunun yerine e-postayı yanıtlayın, gönderici size yazmayı bırakacaktır.",
},
"uk": {
TestTitle: "Це був тестовий лист",
TestBody: "Тестові листи містять посилання, не прив'язане до жодного одержувача, тож відписуватися немає від чого.",
ConfirmTitle: "Відписатися від цих листів?",
ConfirmBody: "Підтвердьте, і ви більше не отримуватимете листів від цього відправника.",
ConfirmButton: "Відписатися",
DoneTitle: "Ви відписалися",
DoneBody: "Ви більше не отримуватимете листів від цього відправника.",
ResubscribeButton: "Відписалися помилково? Підписатися знову",
ResubscribedTitle: "Ви знову підписані",
ResubscribedBody: "Відправник може писати вам, як і раніше. Ви можете відписатися в будь-якому з наступних листів.",
FailedTitle: "Не вдалося обробити посилання",
FailedBody: "Посилання більше не дійсне. Натомість дайте відповідь на лист, і відправник перестане вам писати.",
FailedResubscribeBody: "Посилання більше не дійсне. Дайте відповідь на лист, і відправник зможе знову вас додати.",
RetryTitle: "Спробуйте трохи згодом",
RetryBody: "Зараз не вдалося перевірити це посилання. Відкрийте його знову за кілька хвилин або дайте відповідь на лист, і відправник перестане вам писати.",
ExpiredTitle: "Термін дії посилання закінчився",
InvalidTitle: "Це посилання для відписки недійсне",
ReplyBody: "Натомість дайте відповідь на лист, і відправник перестане вам писати.",
},
"ur": {
RTL: true,
TestTitle: "یہ ایک آزمائشی ای میل تھی",
TestBody: "آزمائشی ای میلز میں موجود لنک کسی وصول کنندہ سے منسلک نہیں ہوتا، اس لیے ان سبسکرائب کرنے کو کچھ نہیں ہے۔",
ConfirmTitle: "کیا آپ ان ای میلز سے ان سبسکرائب کرنا چاہتے ہیں؟",
ConfirmBody: "تصدیق کریں، اس کے بعد آپ کو اس بھیجنے والے کی طرف سے مزید ای میلز موصول نہیں ہوں گی۔",
ConfirmButton: "ان سبسکرائب کریں",
DoneTitle: "آپ کو ان سبسکرائب کر دیا گیا ہے",
DoneBody: "آپ کو اس بھیجنے والے کی طرف سے مزید ای میلز موصول نہیں ہوں گی۔",
ResubscribeButton: "غلطی سے ان سبسکرائب ہو گئے؟ دوبارہ سبسکرائب کریں",
ResubscribedTitle: "آپ دوبارہ سبسکرائب ہو گئے ہیں",
ResubscribedBody: "بھیجنے والا آپ کو پہلے کی طرح ای میل بھیج سکتا ہے۔ آپ آئندہ کسی بھی ای میل سے ان سبسکرائب کر سکتے ہیں۔",
FailedTitle: "ہم اس لنک پر کارروائی نہیں کر سکے",
FailedBody: "یہ لنک اب درست نہیں ہے۔ اس کے بجائے ای میل کا جواب دیں، بھیجنے والا آپ کو ای میل بھیجنا بند کر دے گا۔",
FailedResubscribeBody: "یہ لنک اب درست نہیں ہے۔ ای میل کا جواب دیں، بھیجنے والا آپ کو دوبارہ شامل کر سکتا ہے۔",
RetryTitle: "تھوڑی دیر میں دوبارہ کوشش کریں",
RetryBody: "ہم ابھی اس لنک کی جانچ نہیں کر سکے۔ چند منٹ بعد اسے دوبارہ کھولیں، یا ای میل کا جواب دیں، بھیجنے والا آپ کو ای میل بھیجنا بند کر دے گا۔",
ExpiredTitle: "اس لنک کی میعاد ختم ہو چکی ہے",
InvalidTitle: "یہ ان سبسکرائب لنک درست نہیں ہے",
ReplyBody: "اس کے بجائے ای میل کا جواب دیں، بھیجنے والا آپ کو ای میل بھیجنا بند کر دے گا۔",
},
"vi": {
TestTitle: "Đây là email thử nghiệm",
TestBody: "Email thử nghiệm chứa liên kết không gắn với người nhận nào, vì vậy không có gì để hủy đăng ký.",
ConfirmTitle: "Hủy đăng ký nhận các email này?",
ConfirmBody: "Sau khi xác nhận, bạn sẽ không nhận thêm email nào từ người gửi này.",
ConfirmButton: "Hủy đăng ký",
DoneTitle: "Bạn đã hủy đăng ký",
DoneBody: "Bạn sẽ không nhận thêm email nào từ người gửi này.",
ResubscribeButton: "Lỡ hủy đăng ký? Đăng ký lại",
ResubscribedTitle: "Bạn đã đăng ký lại",
ResubscribedBody: "Người gửi có thể gửi email cho bạn như trước. Bạn có thể hủy đăng ký từ bất kỳ email nào sau này.",
FailedTitle: "Không thể xử lý liên kết này",
FailedBody: "Liên kết này không còn hiệu lực. Thay vào đó, hãy trả lời email và người gửi sẽ ngừng gửi cho bạn.",
FailedResubscribeBody: "Liên kết này không còn hiệu lực. Hãy trả lời email và người gửi có thể thêm lại bạn.",
RetryTitle: "Vui lòng thử lại sau ít phút",
RetryBody: "Hiện chúng tôi chưa thể kiểm tra liên kết này. Hãy mở lại sau vài phút, hoặc trả lời email và người gửi sẽ ngừng gửi cho bạn.",
ExpiredTitle: "Liên kết này đã hết hạn",
InvalidTitle: "Liên kết hủy đăng ký này không hợp lệ",
ReplyBody: "Thay vào đó, hãy trả lời email và người gửi sẽ ngừng gửi cho bạn.",
},
"zh": {
TestTitle: "这是一封测试邮件",
TestBody: "测试邮件中的链接未与任何收件人关联,因此无需退订。",
ConfirmTitle: "要退订这些邮件吗?",
ConfirmBody: "确认后,您将不会再收到该发件人的邮件。",
ConfirmButton: "退订",
DoneTitle: "您已退订",
DoneBody: "您将不会再收到该发件人的邮件。",
ResubscribeButton: "误点了退订?重新订阅",
ResubscribedTitle: "您已重新订阅",
ResubscribedBody: "发件人可以像以前一样给您发送邮件。您可以在之后的任何一封邮件中退订。",
FailedTitle: "无法处理该链接",
FailedBody: "该链接已失效。请直接回复邮件,发件人将不再给您发送邮件。",
FailedResubscribeBody: "该链接已失效。请回复邮件,发件人可以将您重新加入。",
RetryTitle: "请稍后重试",
RetryBody: "暂时无法验证此链接。请几分钟后重新打开,或回复邮件,发件人将不再给您发送邮件。",
ExpiredTitle: "此链接已过期",
InvalidTitle: "此退订链接无效",
ReplyBody: "请直接回复邮件,发件人将不再给您发送邮件。",
},
"zh-Hant": {
TestTitle: "這是一封測試郵件",
TestBody: "測試郵件中的連結未與任何收件人相關聯,因此無須取消訂閱。",
ConfirmTitle: "要取消訂閱這些郵件嗎?",
ConfirmBody: "確認後,您將不會再收到此寄件者的郵件。",
ConfirmButton: "取消訂閱",
DoneTitle: "您已取消訂閱",
DoneBody: "您將不會再收到此寄件者的郵件。",
ResubscribeButton: "不小心取消了?重新訂閱",
ResubscribedTitle: "您已重新訂閱",
ResubscribedBody: "寄件者可以像以前一樣寄信給您。您可以在之後的任何一封郵件中取消訂閱。",
FailedTitle: "無法處理這個連結",
FailedBody: "此連結已失效。請直接回覆郵件,寄件者就會停止寄信給您。",
FailedResubscribeBody: "此連結已失效。請回覆郵件,寄件者就能重新將您加入。",
RetryTitle: "請稍後再試",
RetryBody: "目前無法驗證此連結。請在幾分鐘後重新開啟,或回覆郵件,寄件者就會停止寄信給您。",
ExpiredTitle: "此連結已過期",
InvalidTitle: "此取消訂閱連結無效",
ReplyBody: "請直接回覆郵件,寄件者就會停止寄信給您。",
},
}
@@ -0,0 +1,137 @@
package handler
import (
"html"
"net/http"
"net/http/httptest"
"os"
"reflect"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/app/unsublink"
"github.com/warmbly/warmbly/internal/models"
)
func TestUnsubLanguageFollowsTheBrowser(t *testing.T) {
cases := map[string]string{
"": "en",
"*": "en",
"de-DE,de;q=0.9,en;q=0.8": "de",
"en-US,en;q=0.9,de;q=0.8": "en",
"fr;q=0.5, de;q=0.9": "de",
"xx-YY, pt-BR;q=0.7": "pt",
"de;q=0, fr;q=0.1": "fr",
"de;q=0": "en",
"de;q=abc, it": "it",
"zh-CN,zh;q=0.9": "zh",
"zh-TW": "zh-Hant",
"zh-Hant-HK": "zh-Hant",
"nn-NO": "nb",
"no": "nb",
"tl-PH": "fil",
"sr-Latn-RS": "sr",
"AR-eg": "ar",
"es_MX": "es",
"ja-JP;q=0.8, ko-KR;q=0.8": "ja",
"de;q=0.5,fr;q=NaN": "de",
"de;q=0.5,fr;q=2": "de",
"de;q=0.5,fr;q=+Inf": "de",
}
for header, want := range cases {
if got := unsubLanguage(header); got != want {
t.Errorf("unsubLanguage(%q) = %q, want %q", header, got, want)
}
}
}
// Every tagging language has the whole page, not half of it in English.
func TestUnsubPageCoversEveryTaggingLanguage(t *testing.T) {
codes := []string{"zh-Hant"}
for code := range models.MailLanguageNames {
codes = append(codes, code)
}
for _, code := range codes {
c, ok := unsubCopies[code]
if !ok {
t.Errorf("%s: no unsubscribe page", code)
continue
}
v := reflect.ValueOf(c)
for i := range v.NumField() {
if f := v.Field(i); f.Kind() == reflect.String && strings.TrimSpace(f.String()) == "" {
t.Errorf("%s: %s is empty", code, v.Type().Field(i).Name)
}
}
if code != "en" && c.ConfirmTitle == unsubCopies["en"].ConfirmTitle {
t.Errorf("%s: the confirm page is still in English", code)
}
}
}
func TestUnsubPageRendersTheRecipientsLanguage(t *testing.T) {
signer := unsublink.New("secret", "https://api.example.com")
h := &Handler{UnsubscribeLinks: signer}
get := func(lang string) string {
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/unsubscribe/"+signer.Token(uuid.New(), uuid.New(), uuid.New(), time.Now()), nil)
r.Header.Set("Accept-Language", lang)
newUnsubRouter(h).ServeHTTP(w, r)
if w.Code != http.StatusOK || w.Header().Get("Vary") != "Accept-Language" {
t.Fatalf("%s: %d, Vary %q", lang, w.Code, w.Header().Get("Vary"))
}
return html.UnescapeString(w.Body.String())
}
de := get("de-DE,de;q=0.9")
for _, want := range []string{`<html lang="de">`, unsubCopies["de"].ConfirmTitle, unsubCopies["de"].ConfirmButton} {
if !strings.Contains(de, want) {
t.Errorf("German page lacks %q:\n%s", want, de)
}
}
if ar := get("ar"); !strings.Contains(ar, `<html lang="ar" dir="rtl">`) || !strings.Contains(ar, unsubCopies["ar"].ConfirmTitle) {
t.Errorf("Arabic page is not right to left:\n%s", ar)
}
if en := get("xx"); !strings.Contains(en, `<html lang="en">`) || !strings.Contains(en, "Unsubscribe from these emails?") {
t.Errorf("an unknown language should fall back to English:\n%s", en)
}
// A bad link is refused in the recipient's language too.
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/unsubscribe/nope", nil)
r.Header.Set("Accept-Language", "fr")
newUnsubRouter(h).ServeHTTP(w, r)
if body := html.UnescapeString(w.Body.String()); !strings.Contains(body, unsubCopies["fr"].InvalidTitle) {
t.Errorf("invalid link page not in French:\n%s", body)
}
}
// The tracking service answers a malformed token and an unreachable backend
// itself, with the backend's wording in every language.
func TestTrackingUnsubPagesAgreeWithTheBackend(t *testing.T) {
src, err := os.ReadFile("../../../tracking/src/unsubscribe_i18n.rs")
if err != nil {
t.Fatalf("read tracking copies: %v", err)
}
rust := string(src)
for code, c := range unsubCopies {
at := strings.Index(rust, "\""+code+"\",\n")
if at < 0 {
t.Errorf("%s: no tracking copy", code)
continue
}
block := rust[at:]
if end := strings.Index(block, " ),"); end > 0 {
block = block[:end]
}
for _, s := range []string{c.InvalidTitle, c.ReplyBody, c.RetryTitle} {
if !strings.Contains(block, "\""+s+"\"") {
t.Errorf("%s: tracking copy lacks %q", code, s)
}
}
if strings.Contains(block, "rtl: true") != c.RTL {
t.Errorf("%s: tracking copy disagrees on direction", code)
}
}
}
+55
View File
@@ -113,6 +113,12 @@ var nordicAttribution = quoteMarker{re: regexp.MustCompile(`(?i)(^|\s)(den\s+)?(
// Thunderbird in Russian and Ukrainian: "03.03.2025 10:12, Name пишет:".
var cyrillicAttribution = quoteMarker{re: regexp.MustCompile(`(?i)\d{1,2}[./]\d{1,2}[./]\d{2,4},?\s+\d{1,2}:\d{2},\s[^\n]{0,200}?\s(пишет|пише|написал(\(а\))?|написав(\(ла\))?)\s*:`), lineStart: true}
// gmailSigned is Gmail's attribution in the languages whose verb follows the
// quoted address: "<date> Anna <anna@example.com> kirjutas:".
func gmailSigned(verbs string) quoteMarker {
return quoteMarker{re: regexp.MustCompile(`(?i)@[^\n]{0,120}?[>)]\s*[^\n]{0,30}?(` + verbs + `)\s*[::]`), lineStart: true}
}
var languageRules = map[string]langRules{
"de": {
// GMX and web.de put the date first: "Gesendet: ... Von: ...".
@@ -294,6 +300,55 @@ var languageRules = map[string]langRules{
separators: []string{`הודעה מקורית`, `הודעה שהועברה`},
ooo: []string{"תשובה אוטומטית", "מחוץ למשרד"},
},
"bg": {
quote: []quoteMarker{gmailSigned(`написа`), {re: headerBlock(`от`, `изпратено|дата`)}},
separators: []string{`оригинално съобщение`, `препратено съобщение`},
},
"bn": {quote: []quoteMarker{gmailSigned(`লিখেছেন`)}},
"ca": {
quote: []quoteMarker{gmailSigned(`va escriure`), {re: headerBlock(`de`, `enviat( el)?|data`)}},
separators: []string{`missatge original`, `missatge reenviat`},
},
"et": {
quote: []quoteMarker{gmailSigned(`kirjutas`), {re: headerBlock(`saatja`, `saadetud|kuupäev`)}},
separators: []string{`algne sõnum`, `edasisaadetud sõnum`},
},
"fa": {quote: []quoteMarker{gmailSigned(`نوشت`), {re: headerBlock(`از`, `ارسال شده|تاریخ`)}}},
"fil": {quote: []quoteMarker{gmailSigned(`sumulat`)}},
"hr": {
quote: []quoteMarker{gmailSigned(`(je\s+)?napisa(o|la)(/la)?(\s+je)?`), {re: headerBlock(`šalje|od`, `poslano|datum`)}},
separators: []string{`izvorna poruka`, `proslijeđena poruka`},
},
"lt": {
quote: []quoteMarker{gmailSigned(`rašė`), {re: headerBlock(`nuo`, `išsiųsta|data`)}},
separators: []string{`pradinis laiškas`, `persiųstas laiškas`},
},
"lv": {
quote: []quoteMarker{gmailSigned(`rakstīja`), {re: headerBlock(`no`, `nosūtīts|datums`)}},
separators: []string{`sākotnējais ziņojums`, `pārsūtīts ziņojums`},
},
"ms": {
quote: []quoteMarker{gmailSigned(`menulis`), {re: headerBlock(`daripada`, `dihantar|tarikh`)}},
separators: []string{`mesej asal`, `mesej dimajukan`},
},
"sk": {
quote: []quoteMarker{gmailSigned(`napísal(\(a\)|a)?`), {re: headerBlock(`od`, `odoslané|dátum`)}},
separators: []string{`pôvodná správa`, `preposlaná správa`},
},
"sl": {
quote: []quoteMarker{gmailSigned(`(je\s+)?napisal(\(-?a\)|a)?`), {re: headerBlock(`od`, `poslano|datum`)}},
separators: []string{`izvirno sporočilo`, `posredovano sporočilo`},
},
"sr": {
quote: []quoteMarker{
gmailSigned(`(је\s+|je\s+)?(написао|написала|napisao|napisala)(/ла|/la)?(\s+је|\s+je)?`),
{re: headerBlock(`од|od`, `послато|послано|poslato|poslano|датум|datum`)},
},
separators: []string{`оригинална порука`, `originalna poruka`, `прослеђена порука`, `prosleđena poruka`},
},
"sw": {quote: []quoteMarker{gmailSigned(`aliandika`)}},
"ta": {quote: []quoteMarker{gmailSigned(`எழுதியது`)}},
"ur": {quote: []quoteMarker{gmailSigned(`نے لکھا`)}},
}
// LanguagesWithRules lists the codes that add offline vocabulary, sorted.
+2 -22
View File
@@ -182,16 +182,6 @@ var unsubscribeKeywords = []string{
"meine daten zu loeschen",
}
// optOutLanguages are the languages unsubscribeKeywords carries phrases in.
// Opt-out is read without the workspace's tagging languages, yet a campaign
// written in one of these carries its own opt-out line in that language, and
// the people who answer it quote it with their mail clients' markers ("Von:
// ... Gesendet: ..."). Those markers are always cut first, or every such
// reply would opt its sender out.
var optOutLanguages = []string{"de"}
var optOutPatterns = compileWordPatterns(unsubscribeKeywords)
// gdprObjection is a GDPR Art. 21 objection. The verb also means "I disagree"
// ("ich widerspreche hiermit Ihrer Preisberechnung"), so it counts only when
// the same sentence goes on to what an objection objects to, behind an
@@ -207,25 +197,15 @@ var gdprObjection = regexp.MustCompile(`(^|[^a-z0-9])(widerspreche|wir widerspre
`(?:[^.!?:;,]|\b(?:gem|art|abs|nr|lit|i\.v\.m|z\.b|bzw|ggf)\.){0,90}?` +
`\b(der|dem|den|einer|jeder|jede|jeglicher|jeglichen) (?:[a-z-]+ ){0,2}?[a-z-]*(verarbeitung|nutzung|verwendung|speicherung|weitergabe|zusendung|kontaktaufnahme|werbung|mails|nachrichten|newsletter)`)
// compileWordPatterns anchors each phrase on word boundaries; "don't" and
// "opt-out" keep their apostrophe and hyphen literal.
func compileWordPatterns(phrases []string) []*regexp.Regexp {
out := make([]*regexp.Regexp, 0, len(phrases))
for _, p := range phrases {
out = append(out, regexp.MustCompile(`(^|[^a-z0-9])`+regexp.QuoteMeta(p)+`($|[^a-z0-9])`))
}
return out
}
// quoteFolder folds the typographic apostrophes mail clients substitute while
// typing, so "don’t email me" matches the straight-quote phrase. The opt-out
// scan folds with accentFolder, which does the same and flattens umlauts.
// scan folds with foldOptOut, which does the same and flattens accents.
var quoteFolder = strings.NewReplacer("\u2019", "'", "\u2018", "'", "\u02bc", "'", "\u00b4", "'", "`", "'")
func matchesOptOut(lowerText string) bool {
// A plain-text reply wraps a phrase across lines; the phrases hold one
// space between words.
lowerText = strings.Join(strings.Fields(accentFolder.Replace(lowerText)), " ")
lowerText = foldOptOut(lowerText)
for _, re := range optOutPatterns {
if re.MatchString(lowerText) {
return true
@@ -0,0 +1,299 @@
package replyclassify
import (
"regexp"
"sort"
"strings"
"unicode"
"golang.org/x/text/unicode/norm"
)
// optOutPhrases are opt-out requests in the tagging languages besides English
// and German, which unsubscribeKeywords carries. Each is written as a person
// types it and folded like the reply before matching. Every one names the
// writer or the mail, so a word that also cancels a meeting or an order never
// counts alone. A "*" at either end leaves that end open for a prefix or a
// suffix the language attaches to the word ("abonelikten çık*" reads
// "çıkarın").
var optOutPhrases = map[string][]string{
"ar": {
"إلغاء الاشتراك", "الغي اشتراكي", "ألغوا اشتراكي", "*احذفوني", "*احذفني", "احذف بريدي", "احذفوا بريدي",
"*أزيلوني", "أزيلوا بريدي", "*توقفوا عن مراسلتي", "*توقف عن مراسلتي", "*توقفوا عن إرسال",
"لا ترسلوا لي المزيد", "لا ترسل لي المزيد", "لا ترسلوا لي أي رسائل", "*لا تراسلوني", "لا تتواصلوا معي", "لا تتواصل معي", "احذفوا بياناتي",
},
"bg": {
"отпишете ме", "отпишете ни", "искам да се отпиша", "премахнете ме", "изтрийте ме", "изтрийте данните ми",
"не ми пишете повече", "не ми изпращайте повече", "спрете да ми изпращате", "спрете да ми пишете", "не се свързвайте с мен",
},
"bn": {
"আনসাবস্ক্রাইব", "সাবস্ক্রিপশন বাতিল*", "আমাকে তালিকা থেকে বাদ*", "আমাকে বাদ দিন", "আমাকে সরিয়ে দিন",
"ইমেইল পাঠাবেন না", "ইমেল পাঠাবেন না", "আর পাঠাবেন না", "আমার সাথে যোগাযোগ করবেন না", "আমার তথ্য মুছে*",
},
"ca": {
"donar-me de baixa", "doneu-me de baixa", "doni'm de baixa", "vull donar-me de baixa", "baixa de la llista",
"esborreu-me", "elimineu-me", "traieu-me de", "esborreu les meves dades", "no m'envieu més", "deixeu d'enviar-me",
"no em contacteu més", "no vull rebre més",
},
"cs": {
"odhlaste mě", "odhlašte mě", "odhlásit z odběru", "odhlásit mě", "zrušit odběr", "zrušte odběr", "odstraňte mě",
"odstraňte mou adresu", "smažte mě", "smažte mé údaje", "vymažte mé údaje", "neposílejte mi už", "neposílejte mi další", "přestaňte mi posílat",
"nekontaktujte mě", "nechci dostávat",
},
"da": {
"afmeld mig", "frameld mig", "fjern mig", "slet mig", "slet mine oplysninger", "slet mine data",
"stop med at sende", "stop med at skrive", "send ikke flere", "ikke flere mails", "ikke flere e-mails",
"kontakt mig ikke", "kontakt os ikke", "ønsker ikke at modtage",
},
"el": {
"διαγράψτε με", "διαγραφή από τη λίστα", "απεγγραφή", "απεγγράψτε με", "αφαιρέστε με", "σταματήστε να μου στέλνετε",
"μη μου ξαναστείλετε", "μην μου ξαναστείλετε", "μην επικοινωνείτε μαζί μου", "διαγράψτε τα στοιχεία μου",
},
"es": {
"darme de baja", "dame de baja", "denme de baja", "dadme de baja", "darnos de baja", "de baja de su lista",
"de baja de la lista", "cancelar la suscripción", "cancelar mi suscripción", "desuscribirme", "desinscribirme",
"elimínenme", "elimíname", "eliminarme de", "quítenme de", "quítame de", "sáquenme de", "sácame de",
"borren mis datos", "eliminen mis datos", "no me envíen más", "no me envíes más", "dejen de enviarme", "deja de enviarme",
"dejen de escribirme", "no me contacten más", "no vuelvan a contactarme", "no quiero recibir más", "no deseo recibir más",
},
"et": {
"loobun tellimusest", "tühistage tellimus", "eemaldage mind", "eemalda mind", "kustutage mind", "kustutage minu andmed",
"ärge saatke mulle enam", "ärge saatke mulle rohkem", "ära saada mulle enam", "lõpetage kirjade saatmine", "ärge võtke minuga ühendust", "ära võta minuga ühendust",
},
"fa": {
"لغو اشتراک", "لغو عضویت", "مرا از لیست حذف", "من را از لیست حذف", "منو از لیست حذف", "ایمیل من را حذف",
"دیگر ایمیل نفرستید", "ایمیل نفرستید", "دیگر پیام نفرستید", "با من تماس نگیرید", "اطلاعات من را حذف",
},
"fi": {
"peru tilaus", "peruuta tilaukseni", "poistakaa minut", "poista minut", "poistakaa sähköpostiosoitteeni", "poistakaa tietoni",
"älkää lähettäkö minulle enää", "älä lähetä minulle enää", "lopettakaa viestien lähettäminen", "älkää ottako minuun yhteyttä",
"älä ota minuun yhteyttä", "en halua enää viestejä", "en halua enää sähköpost*",
},
"fil": {
"alisin ako sa", "tanggalin ako sa", "huwag na akong padalhan", "huwag nyo na akong padalhan", "huwag niyo na akong padalhan",
"huwag na akong i-email", "huwag na akong kontakin", "ayoko nang makatanggap", "burahin ang aking data",
},
"fr": {
"désabonner", "désabonnez-moi", "me désabonner", "désinscrire", "désinscrivez-moi", "me désinscrire", "désinscription",
"retirez-moi de", "retirez mon adresse", "supprimez-moi de", "supprimez mon adresse", "supprimez mes données",
"ne m'envoyez plus", "arrêtez de m'envoyer", "cessez de m'envoyer", "ne me contactez plus", "ne plus me contacter",
"ne souhaite plus recevoir", "ne veux plus recevoir", "plus aucun mail", "plus aucun e-mail", "plus aucun email",
},
"he": {
"*הסירו אותי", "*הסר אותי", "*הסירי אותי", "להסיר אותי מהרשימה", "*בטלו את המנוי", "ביטול מנוי", "*אל תשלחו לי יותר",
"*אל תשלח לי יותר", "*אל תשלחו לי עוד", "*תפסיקו לשלוח", "*אל תפנו אליי", "*אל תיצרו איתי קשר", "*מחקו את הפרטים שלי",
},
"hi": {
"अनसब्सक्राइब", "सदस्यता रद्द*", "मुझे सूची से हटा*", "मुझे हटा दें", "मुझे हटाएं", "मुझे हटाएँ",
"ईमेल न भेजें", "ईमेल मत भेजो", "ईमेल मत भेजिए", "ईमेल भेजना बंद*", "मुझसे संपर्क न करें", "मेरा डेटा हटा*",
},
"hr": {
"odjavite me", "odjava s liste", "maknite me s", "uklonite me", "izbrišite me", "izbrišite moje podatke",
"obrišite moje podatke", "ne šaljite mi više", "prestanite mi slati", "nemojte mi više slati", "nemojte mi slati više", "ne kontaktirajte me",
"nemojte me kontaktirati", "ne pišite mi više",
},
"hu": {
"leiratkoz*", "iratkoztasson le", "iratkoztassanak le", "töröljenek a listá*", "töröljön a listá*",
"töröljék az adataim*", "töröljenek az adatbázis*", "ne küldjenek több", "ne küldjenek nekem", "ne küldjön több", "ne írjanak többet",
"ne keressenek többet", "ne keressenek a továbbiakban", "nem kérek több e-mail*", "nem kérek több level*",
},
"id": {
"berhenti berlangganan", "hapus saya dari", "hapus email saya", "hapus data saya", "keluarkan saya dari",
"jangan kirim email lagi", "jangan kirimi saya", "jangan hubungi saya", "tidak ingin menerima email",
},
"it": {
"disiscrivermi", "disiscrivetemi", "cancellatemi dalla", "cancellami dalla", "cancellarmi dalla", "cancellate il mio indirizzo",
"cancellate i miei dati", "rimuovetemi", "rimuovermi dalla", "rimuovete il mio indirizzo", "rimuovete la mia email", "toglietemi dalla", "non inviatemi più",
"non mandatemi più", "smettete di inviarmi", "smettete di scrivermi", "non contattatemi più", "non voglio più ricevere",
"non desidero più ricevere", "disiscrizione",
},
"ja": {
"配信停止", "配信を停止", "配信不要", "配信解除", "購読解除", "登録解除", "メール不要", "今後のご連絡は不要",
"今後の連絡は不要", "今後連絡しないでください", "もう連絡しないでください", "メールを送らないでください", "もう送らないでください",
"今後送らないでください", "リストから削除", "リストから外して", "メールを停止",
},
"ko": {
"수신거부", "수신 거부", "수신을 거부", "구독 취소", "구독취소", "구독 해지", "메일 보내지 마", "메일을 보내지 마",
"이메일 보내지 마", "이메일을 보내지 마", "더 이상 보내지 마", "더 이상 연락하지 마", "다시 연락하지 마",
"명단에서 삭제", "목록에서 삭제", "리스트에서 삭제", "제 정보를 삭제",
},
"lt": {
"atsisakyti prenumeratos", "atsisakau prenumeratos", "išbraukite mane", "pašalinkite mane", "ištrinkite mane",
"ištrinkite mano duomenis", "nebesiųskite", "nesiųskite man daugiau", "nebesikreipkite", "nebesusisiekite", "nebenoriu gauti",
},
"lv": {
"atrakstīties", "atrakstiet mani", "atrakstīt mani", "izņemiet mani no", "izdzēsiet mani", "dzēsiet manus datus",
"nesūtiet man vairs", "vairs nesūtiet", "pārtrauciet sūtīt", "nesazinieties ar mani", "vairs nerakstiet",
},
"ms": {
"berhenti melanggan", "nyahlanggan", "buang saya dari", "keluarkan saya dari", "padamkan saya", "padam data saya",
"jangan hantar lagi", "jangan hantar e-mel", "jangan hantar emel", "jangan hubungi saya", "tidak mahu menerima",
},
"nb": {
"avmeld meg", "meld meg av listen", "meld meg ut av", "fjern meg fra", "slett meg", "slett mine opplysninger",
"slutt å sende", "ikke send flere", "ikke flere e-poster", "ikke flere mail", "ikke kontakt meg", "ønsker ikke å motta",
},
"nl": {
"afmelden van", "graag afmelden", "meld mij af van", "meld me af van", "uitschrijven", "schrijf mij uit", "schrijf me uit",
"haal mij van", "haal me van", "verwijder mij", "verwijder me", "verwijder mijn gegevens", "verwijder mijn e-mailadres",
"geen e-mails meer", "geen mails meer", "stuur mij geen mails", "stuur me geen mails", "stuur mij geen e-mails",
"stuur me geen e-mails", "stuur me geen berichten meer", "neem geen contact meer", "niet meer benaderen",
"niet meer mailen", "stop met mailen",
},
"pl": {
"wypisz mnie", "wypiszcie mnie", "proszę mnie wypisać", "wypisać mnie", "usuńcie mnie", "usuń mnie", "proszę mnie usunąć",
"usunąć mnie z", "usuńcie moje dane", "usunięcie moich danych", "proszę nie wysyłać więcej", "proszę nie wysyłać mi",
"nie wysyłajcie mi", "nie wysyłaj mi więcej", "proszę się ze mną nie kontaktować", "nie kontaktujcie się ze mną", "rezygnuję z subskrypcji", "wypisanie z listy",
},
"pt": {
"descadastrar", "descadastre", "me descadastrar", "cancelar a inscrição", "cancelar minha inscrição", "cancelar a subscrição",
"remover da lista", "remova meu e-mail", "remova o meu e-mail", "remova-me", "me remova", "retire-me", "me retire da",
"me tire da lista", "não me envie mais", "não me enviem mais", "parem de me enviar", "pare de me enviar",
"não quero receber mais", "não desejo receber mais", "não me contate mais", "não me contactem mais",
"apaguem os meus dados", "apague meus dados", "excluam meus dados",
},
"ro": {
"dezabonare", "dezabonați-mă", "dezabonează-mă", "mă dezabonez", "scoateți-mă de pe listă", "scoateți-mă din",
"eliminați-mă", "ștergeți-mă", "ștergeți datele mele", "nu-mi mai trimiteți", "nu mai trimiteți", "nu mă mai contactați",
"opriți trimiterea",
},
"ru": {
"отпишите меня", "отпишите нас", "отписаться", "удалите меня", "удалите мой адрес", "удалите мои данные",
"исключите меня из", "уберите меня из", "не пишите мне больше", "не присылайте мне больше", "не присылайте больше",
"больше не пишите", "больше не присылайте", "прекратите рассылку", "прекратите присылать", "не связывайтесь со мной",
"не беспокойте меня больше",
},
"sk": {
"odhláste ma", "odhlásiť ma", "odhlásiť z odberu", "zrušiť odber", "zrušte odber", "odstráňte ma", "vymažte ma",
"vymažte moje údaje", "neposielajte mi už", "neposielajte mi ďalšie", "prestaňte mi posielať", "nekontaktujte ma", "nechcem dostávať",
},
"sl": {
"odjavite me", "odjava od prejemanja", "odstranite me", "izbrišite me", "izbrišite moje podatke", "ne pošiljajte mi več",
"nehajte mi pošiljati", "ne kontaktirajte me", "ne pišite mi več",
},
"sr": {
"одјавите ме", "уклоните ме", "избришите ме", "обришите моје податке", "не шаљите ми више", "престаните да ми шаљете",
"не контактирајте ме", "не пишите ми више",
"odjavite me", "uklonite me", "izbrišite me", "obrišite moje podatke", "ne šaljite mi više", "prestanite da mi šaljete",
"ne kontaktirajte me", "ne pišite mi više",
},
"sv": {
"avregistrera mig", "avsluta prenumerationen", "avprenumerera", "ta bort mig", "ta bort min e-post", "ta bort mina uppgifter",
"sluta skicka", "sluta mejla", "skicka inga fler", "inga fler mejl", "inga fler mail", "kontakta mig inte",
"kontakta oss inte", "vill inte få fler",
},
"sw": {
"niondoe kwenye orodha", "niondoe", "niondoeni", "acha kunitumia", "acheni kunitumia", "usinitumie", "msinitumie",
"usiwasiliane nami", "sitaki kupokea", "futa taarifa zangu",
},
"ta": {
"குழுவிலகு*", "சந்தாவை ரத்து*", "என்னை பட்டியலிலிருந்து நீக்க*", "என்னை நீக்க*", "மின்னஞ்சல் அனுப்ப வேண்டாம்",
"இனி அனுப்ப வேண்டாம்", "என்னை தொடர்பு கொள்ள வேண்டாம்", "எனது தகவல்களை நீக்க*",
},
"th": {
"ยกเลิกการสมัคร", "ยกเลิกการรับข่าวสาร", "ลบฉันออกจาก", "ลบผมออกจาก", "ลบอีเมลของฉัน", "ไม่ต้องส่งมาอีก",
"อย่าส่งมาอีก", "หยุดส่งอีเมล", "ไม่ต้องติดต่อมาอีก", "ไม่ต้องการรับอีเมล",
},
"tr": {
"abonelikten çık*", "listeden çıkar*", "listenizden çıkar*", "beni listeden", "beni sil*", "beni çıkar*",
"mail göndermeyin", "e-posta göndermeyin", "bana mail atmayın", "bana e-posta göndermeyin", "benimle iletişime geçmeyin",
"bir daha yazmayın", "verilerimi sil*",
},
"uk": {
"відпишіть мене", "відписатися", "видаліть мене", "видаліть мою адресу", "видаліть мої дані", "не пишіть мені більше",
"не надсилайте мені більше", "більше не пишіть", "більше не надсилайте", "припиніть розсилку", "не турбуйте мене більше",
"не контактуйте зі мною",
},
"ur": {
"ان سبسکرائب", "سبسکرپشن منسوخ", "مجھے فہرست سے نکال*", "مجھے ہٹا دیں", "مجھے نکال دیں", "ای میل نہ بھیجیں",
"ای میل مت بھیجیں", "مزید ای میل نہ", "مجھ سے رابطہ نہ کریں", "میرا ڈیٹا حذف",
},
"vi": {
"hủy đăng ký", "huỷ đăng ký", "xóa tôi khỏi", "xoá tôi khỏi", "gỡ tôi khỏi", "đừng gửi email", "đừng gửi thư",
"ngừng gửi email", "ngừng gửi thư", "không gửi email cho tôi nữa", "đừng liên hệ với tôi", "xóa thông tin của tôi",
"không muốn nhận email",
},
"zh": {
"取消订阅", "取消訂閱", "退订", "退訂", "不要再发", "不要再發", "别再发", "別再發", "请勿再发", "請勿再發",
"从列表中删除", "從名單中刪除", "从名单中删除", "把我从", "把我從", "删除我的邮箱", "删除我的信息", "删除我的资料",
"刪除我的資料", "不要再联系我", "不要再聯絡我", "别再联系我", "別再聯絡我", "请不要再给我发", "請不要再寄",
},
}
// optOutLanguages are the languages the opt-out phrases are in. Opt-out is
// read without the workspace's tagging languages, yet a campaign written in
// one of these carries its own opt-out line in that language, and the people
// who answer it quote it with their mail clients' markers ("Von: ...
// Gesendet: ..."). Those markers are always cut first, or every such reply
// would opt its sender out.
var optOutLanguages = func() []string {
out := []string{"de"}
for code := range optOutPhrases {
out = append(out, code)
}
sort.Strings(out)
return out
}()
var optOutPatterns = compileWordPatterns(allOptOutPhrases())
func allOptOutPhrases() []string {
out := append([]string(nil), unsubscribeKeywords...)
for _, code := range optOutLanguages {
out = append(out, optOutPhrases[code]...)
}
return out
}
// compileWordPatterns anchors each phrase on word boundaries in any script;
// "don't" and "opt-out" keep their apostrophe and hyphen literal. Scripts
// written without spaces between words, and an end marked "*", stay open.
func compileWordPatterns(phrases []string) []*regexp.Regexp {
const boundary = `[^\pL\pM\pN]`
out := make([]*regexp.Regexp, 0, len(phrases))
for _, p := range phrases {
openStart, openEnd := strings.HasPrefix(p, "*"), strings.HasSuffix(p, "*")
p = foldOptOut(strings.Trim(p, "*"))
first, last := []rune(p)[0], []rune(p)[len([]rune(p))-1]
re := regexp.QuoteMeta(p)
if !openStart && !unspaced(first) {
re = `(^|` + boundary + `)` + re
}
if !openEnd && !unspaced(last) {
re += `($|` + boundary + `)`
}
out = append(out, regexp.MustCompile(re))
}
return out
}
// unspaced is a letter of a script that runs its words together, where a
// phrase has no boundary to stand on.
func unspaced(r rune) bool {
return unicode.In(r, unicode.Han, unicode.Hiragana, unicode.Katakana, unicode.Hangul, unicode.Thai)
}
// optOutFolder evens out spellings a phrase list would otherwise need twice:
// Greek's final sigma, the "đ" of Vietnamese typed without marks, and the
// Arabic letters Persian and Urdu keyboards type in their own form.
var optOutFolder = strings.NewReplacer("ς", "σ", "đ", "d", "أ", "ا", "إ", "ا", "آ", "ا", "ي", "ی", "ك", "ک", "ى", "ی")
// foldOptOut is what both a phrase and a reply are matched as: lower case,
// accents flattened (the Latin and Greek marks accentFolder has no entry for
// are dropped), single spaces.
func foldOptOut(s string) string {
s = accentFolder.Replace(strings.ToLower(norm.NFC.String(s)))
var b strings.Builder
strip := false
for _, r := range norm.NFD.String(s) {
if unicode.Is(unicode.Mn, r) {
if strip {
continue
}
} else {
strip = unicode.In(r, unicode.Latin, unicode.Greek)
}
b.WriteRune(r)
}
return strings.Join(strings.Fields(optOutFolder.Replace(norm.NFC.String(b.String()))), " ")
}
@@ -0,0 +1,159 @@
package replyclassify
import (
"strings"
"testing"
"github.com/warmbly/warmbly/internal/models"
)
// Every tagging language carries opt-out phrases, and every language with
// phrases has quote markers to cut its campaigns' own opt-out line with.
func TestOptOutCoversEveryTaggingLanguage(t *testing.T) {
for code := range models.MailLanguageNames {
if code == "en" || code == "de" {
continue
}
if len(optOutPhrases[code]) == 0 {
t.Errorf("%s: no opt-out phrases", code)
}
}
for _, code := range optOutLanguages {
if len(languageRules[code].quote) == 0 {
t.Errorf("%s: opt-out phrases but no quote markers", code)
}
}
}
// Each phrase opts out as the reply writes it, with the suffix or prefix its
// "*" leaves room for, in upper case and wrapped onto the next line.
func TestEveryOptOutPhraseMatchesItsReply(t *testing.T) {
for code, phrases := range optOutPhrases {
for _, p := range phrases {
word := strings.Trim(p, "*")
if strings.HasSuffix(p, "*") {
word += "in"
}
if strings.HasPrefix(p, "*") {
word = "و" + word
}
for _, body := range []string{
"Hello,\n" + word + "\nThanks",
strings.ToUpper(word) + ".",
strings.Replace(word, " ", "\n", 1) + ".",
} {
if !IsOptOut("Re: Hello", body) {
t.Errorf("%s: %q not an opt-out in %q", code, p, body)
}
}
}
}
}
// Replies in each language, as people write them.
func TestIsOptOutInEveryLanguage(t *testing.T) {
optOut := map[string][]string{
"ar": {"من فضلكم احذفوني من القائمة", "أرجو إلغاء الاشتراك", "لا ترسلوا لي أي رسائل أخرى"},
"bg": {"Моля, отпишете ме от списъка.", "Не ми пишете повече."},
"bn": {"দয়া করে আমাকে তালিকা থেকে বাদ দিন।"},
"ca": {"Si us plau, doneu-me de baixa.", "No m'envieu més correus."},
"cs": {"Prosím odhlaste mě z odběru.", "Neposílejte mi už žádné e-maily."},
"da": {"Fjern mig venligst fra listen.", "Jeg ønsker ikke at modtage flere mails."},
"el": {"Παρακαλώ διαγράψτε με από τη λίστα.", "ΣΤΑΜΑΤΗΣΤΕ ΝΑ ΜΟΥ ΣΤΕΛΝΕΤΕ"},
"es": {"Por favor, denme de baja.", "No me envíen más correos.", "Quitenme de su lista"},
"et": {"Palun eemaldage mind nimekirjast.", "Ärge saatke mulle enam kirju."},
"fa": {"لطفا مرا از لیست حذف کنید.", "دیگر ایمیل نفرستید"},
"fi": {"Poistakaa minut listaltanne.", "Älkää lähettäkö minulle enää viestejä."},
"fil": {"Pakiusap, alisin ako sa listahan.", "Huwag na akong padalhan ng email."},
"fr": {"Merci de me désinscrire.", "Ne m’envoyez plus de messages.", "Desabonnez-moi svp"},
"he": {"בבקשה הסירו אותי מהרשימה", "ותפסיקו לשלוח לי מיילים"},
"hi": {"कृपया मुझे सूची से हटाएं।", "मुझे ईमेल मत भेजो"},
"hr": {"Molim vas, odjavite me.", "Nemojte mi slati više poruka."},
"hu": {"Kérem, iratkoztasson le a listáról.", "Leiratkozom.", "Ne küldjenek több levelet."},
"id": {"Tolong hapus saya dari daftar.", "Jangan kirim email lagi."},
"it": {"Per favore cancellatemi dalla lista.", "Non inviatemi più email."},
"ja": {"配信停止をお願いします。", "今後メールを送らないでください。"},
"ko": {"수신거부합니다.", "더 이상 연락하지 마세요."},
"lt": {"Prašau išbraukite mane iš sąrašo.", "Nebesiųskite man laiškų."},
"lv": {"Lūdzu, izņemiet mani no saraksta.", "Nesūtiet man vairs e-pastus."},
"ms": {"Sila keluarkan saya dari senarai.", "Jangan hantar e-mel lagi."},
"nb": {"Vennligst fjern meg fra listen.", "Ikke kontakt meg igjen."},
"nl": {"Graag afmelden.", "Verwijder mij uit uw bestand.", "Stuur me geen mails meer."},
"pl": {"Proszę mnie wypisać z listy.", "Nie wysyłajcie mi więcej wiadomości."},
"pt": {"Por favor, me remova da lista.", "Não me enviem mais e-mails.", "Nao quero receber mais"},
"ro": {"Vă rog să mă scoateți-mă din listă.", "Nu-mi mai trimiteți emailuri.", "Stergeti-ma"},
"ru": {"Пожалуйста, отпишите меня.", "Больше не пишите мне.", "Удалите мой адрес из базы."},
"sk": {"Prosím, odhláste ma.", "Neposielajte mi už e-maily."},
"sl": {"Prosim, odjavite me.", "Ne pošiljajte mi več sporočil."},
"sr": {"Молим вас, одјавите ме.", "Ne šaljite mi više poruke."},
"sv": {"Ta bort mig från listan.", "Sluta skicka mejl till mig."},
"sw": {"Tafadhali niondoe kwenye orodha.", "Acheni kunitumia barua pepe."},
"ta": {"தயவுசெய்து என்னை பட்டியலிலிருந்து நீக்கவும்."},
"th": {"กรุณาลบฉันออกจากรายชื่อ", "ไม่ต้องส่งมาอีกนะครับ"},
"tr": {"Lütfen beni listeden çıkarın.", "Abonelikten çıkmak istiyorum.", "Bana mail atmayın."},
"uk": {"Будь ласка, відпишіть мене.", "Більше не надсилайте листів."},
"ur": {"براہ کرم مجھے فہرست سے نکال دیں۔"},
"vi": {"Vui lòng xóa tôi khỏi danh sách.", "Đừng gửi email cho tôi nữa.", "Huy dang ky"},
"zh": {"请把我从邮件列表中删除。", "請不要再寄信給我。", "取消订阅"},
}
for code, replies := range optOut {
for _, r := range replies {
if !IsOptOut("Re: Hello", r) {
t.Errorf("%s: %q not read as an opt-out", code, r)
}
}
}
// A word that also cancels a meeting, a leave, or means "don't worry" is
// not a request to stop.
for _, r := range []string{
"Estoy de baja hasta el lunes, le escribo luego.",
"Ik moet me afmelden voor de webinar van morgen.",
"Envoyez-moi plus de mails sur ce sujet, merci.",
"Не беспокойтесь, всё в порядке.",
"資料は送らないでください、リンクで十分です。",
"Posso cancellare la riunione di domani?",
"Kan du avmelde møtet på fredag?",
"Moram otkazati sastanak, javim se.",
"Мы удалили старую версию, пришлите новую.",
"Interesante, hablemos la próxima semana.",
"Vielen Dank, das klingt gut.",
"Не присылайте счёт, мы уже оплатили.",
"Не пишите мне сюда, лучше на рабочую почту.",
"Stuur me geen factuur, die heb ik al.",
"電話で連絡しないでください、メールでお願いします。",
"Proszę nie wysyłać faktury papierowej.",
"Neposílejte mi fakturu poštou.",
"Älä lähetä minulle laskua paperilla.",
"אל תשלחו לי חשבונית, כבר שילמנו.",
} {
if IsOptOut("Re: Hello", r) {
t.Errorf("%q read as an opt-out", r)
}
}
}
// A campaign's own opt-out line, quoted under the reply in its language's
// attribution, is the sender's text and never opts the replier out.
func TestQuotedOptOutLineInEveryLanguage(t *testing.T) {
seen := map[string]bool{}
for _, c := range attributionCases {
phrases := optOutPhrases[c.lang]
if len(phrases) == 0 || seen[c.lang] {
continue
}
seen[c.lang] = true
line := "Reply «" + strings.Trim(phrases[0], "*") + "» to stop."
body := "Thanks, sounds good.\n\n" + c.line + "\nHello Anna, a short note.\n" + line
if IsOptOut("Re: Hello", body) {
t.Errorf("%s: the quoted opt-out line opted out: %q", c.lang, body)
}
if !IsOptOut("Re: Hello", strings.Trim(phrases[0], "*")+"\n\n"+c.line+"\nHello Anna.") {
t.Errorf("%s: the reply's own opt-out was cut", c.lang)
}
}
for code := range optOutPhrases {
if !seen[code] {
t.Errorf("%s: no attribution case to quote under", code)
}
}
}
@@ -11,44 +11,7 @@ const quotedTail = "\n> Hello Anna, a short note about working together.\n> Repl
// it in that language, ends the reply once the workspace reads that language.
// base marks the ones every workspace has always had.
func TestStripQuotedAttributionsByLanguage(t *testing.T) {
cases := []struct {
lang, line string
base bool
}{
{"en", "On Mon, 3 Mar 2025 at 10:12, Anna <anna@example.com> wrote:", true},
{"de", "Am Mo., 3. März 2025 um 10:12 Uhr schrieb Anna <anna@example.com>:", true},
{"fr", "Le lun. 3 mars 2025 à 10:12, Anna <anna@example.com> a écrit :", true},
{"es", "El lun, 3 mar 2025 a las 10:12, Anna (<anna@example.com>) escribió:", true},
{"it", "Il giorno lun 3 mar 2025 alle ore 10:12 Anna <anna@example.com> ha scritto:", true},
{"nl", "Op ma 3 mrt 2025 om 10:12 schreef Anna <anna@example.com>:", true},
{"pt", "Em seg., 3 de mar. de 2025 às 10:12, Anna <anna@example.com> escreveu:", false},
{"sv", "Den mån 3 mars 2025 kl 10:12 skrev Anna <anna@example.com>:", false},
{"da", "Den man. 3. mar. 2025 kl. 10.12 skrev Anna <anna@example.com>:", false},
{"nb", "man. 3. mar. 2025 kl. 10:12 skrev Anna <anna@example.com>:", false},
{"fi", "ma 3. maalisk. 2025 klo 10.12 Anna (anna@example.com) kirjoitti:", false},
{"pl", "pon., 3 mar 2025 o 10:12 Anna <anna@example.com> napisał(a):", false},
{"pl", "W dniu 3.03.2025 o 10:12, Anna pisze:", false},
{"cs", "po 3. 3. 2025 v 10:12 odesílatel Anna <anna@example.com> napsal:", false},
{"ro", "Pe lun., 3 mar. 2025 la 10:12, Anna <anna@example.com> a scris:", false},
{"hu", "Anna <anna@example.com> ezt írta (időpont: 2025. márc. 3., H, 10:12):", false},
{"hu", "2025. 03. 03. 10:12 keltezéssel, Anna írta:", false},
{"ru", "пн, 3 мар. 2025 г. в 10:12, Anna <anna@example.com>:", false},
{"ru", "03.03.2025 10:12, Anna пишет:", false},
{"uk", "пн, 3 бер. 2025 р. о 10:12 Anna <anna@example.com> пише:", false},
{"el", "Στις Δευ 3 Μαρ 2025 στις 10:12, ο/η Anna <anna@example.com> έγραψε:", false},
{"tr", "Anna <anna@example.com>, 3 Mar 2025 Pzt, 10:12 tarihinde şunu yazdı:", false},
{"ja", "2025年3月3日(月) 10:12 Anna <anna@example.com>:", false},
{"zh", "Anna <anna@example.com> 于2025年3月3日周一 10:12写道:", false},
{"zh", "Anna <anna@example.com> 於 2025年3月3日 週一 上午10:12寫道:", false},
{"ko", "2025년 3월 3일 (월) 오전 10:12, Anna <anna@example.com>님이 작성:", false},
{"id", "Pada Sen, 3 Mar 2025 pukul 10.12 Anna <anna@example.com> menulis:", false},
{"vi", "Vào Th 2, 3 thg 3, 2025 vào lúc 10:12 Anna <anna@example.com> đã viết:", false},
{"th", "เมื่อ จ. 3 มี.ค. 2025 เวลา 10:12 Anna <anna@example.com> เขียนว่า:", false},
{"hi", "सोम, 3 मार्च 2025 को 10:12 am बजे Anna <anna@example.com> ने लिखा:", false},
{"ar", "في الاثنين، 3 مارس 2025 في 10:12 ص كتب Anna <anna@example.com>:", false},
{"he", "בתאריך יום ב׳, 3 במרץ 2025 ב-10:12 מאת Anna <anna@example.com>:", false},
}
for _, c := range cases {
for _, c := range attributionCases {
body := "The reply itself.\n\n" + c.line + quotedTail
if got := StripQuoted(body, c.lang); got != "The reply itself." {
t.Errorf("%s: StripQuoted = %q", c.lang, got)
@@ -63,6 +26,60 @@ func TestStripQuotedAttributionsByLanguage(t *testing.T) {
}
}
var attributionCases = []struct {
lang, line string
base bool
}{
{"en", "On Mon, 3 Mar 2025 at 10:12, Anna <anna@example.com> wrote:", true},
{"de", "Am Mo., 3. März 2025 um 10:12 Uhr schrieb Anna <anna@example.com>:", true},
{"fr", "Le lun. 3 mars 2025 à 10:12, Anna <anna@example.com> a écrit :", true},
{"es", "El lun, 3 mar 2025 a las 10:12, Anna (<anna@example.com>) escribió:", true},
{"it", "Il giorno lun 3 mar 2025 alle ore 10:12 Anna <anna@example.com> ha scritto:", true},
{"nl", "Op ma 3 mrt 2025 om 10:12 schreef Anna <anna@example.com>:", true},
{"pt", "Em seg., 3 de mar. de 2025 às 10:12, Anna <anna@example.com> escreveu:", false},
{"sv", "Den mån 3 mars 2025 kl 10:12 skrev Anna <anna@example.com>:", false},
{"da", "Den man. 3. mar. 2025 kl. 10.12 skrev Anna <anna@example.com>:", false},
{"nb", "man. 3. mar. 2025 kl. 10:12 skrev Anna <anna@example.com>:", false},
{"fi", "ma 3. maalisk. 2025 klo 10.12 Anna (anna@example.com) kirjoitti:", false},
{"pl", "pon., 3 mar 2025 o 10:12 Anna <anna@example.com> napisał(a):", false},
{"pl", "W dniu 3.03.2025 o 10:12, Anna pisze:", false},
{"cs", "po 3. 3. 2025 v 10:12 odesílatel Anna <anna@example.com> napsal:", false},
{"ro", "Pe lun., 3 mar. 2025 la 10:12, Anna <anna@example.com> a scris:", false},
{"hu", "Anna <anna@example.com> ezt írta (időpont: 2025. márc. 3., H, 10:12):", false},
{"hu", "2025. 03. 03. 10:12 keltezéssel, Anna írta:", false},
{"ru", "пн, 3 мар. 2025 г. в 10:12, Anna <anna@example.com>:", false},
{"ru", "03.03.2025 10:12, Anna пишет:", false},
{"uk", "пн, 3 бер. 2025 р. о 10:12 Anna <anna@example.com> пише:", false},
{"el", "Στις Δευ 3 Μαρ 2025 στις 10:12, ο/η Anna <anna@example.com> έγραψε:", false},
{"tr", "Anna <anna@example.com>, 3 Mar 2025 Pzt, 10:12 tarihinde şunu yazdı:", false},
{"ja", "2025年3月3日(月) 10:12 Anna <anna@example.com>:", false},
{"zh", "Anna <anna@example.com> 于2025年3月3日周一 10:12写道:", false},
{"zh", "Anna <anna@example.com> 於 2025年3月3日 週一 上午10:12寫道:", false},
{"ko", "2025년 3월 3일 (월) 오전 10:12, Anna <anna@example.com>님이 작성:", false},
{"id", "Pada Sen, 3 Mar 2025 pukul 10.12 Anna <anna@example.com> menulis:", false},
{"vi", "Vào Th 2, 3 thg 3, 2025 vào lúc 10:12 Anna <anna@example.com> đã viết:", false},
{"th", "เมื่อ จ. 3 มี.ค. 2025 เวลา 10:12 Anna <anna@example.com> เขียนว่า:", false},
{"hi", "सोम, 3 मार्च 2025 को 10:12 am बजे Anna <anna@example.com> ने लिखा:", false},
{"ar", "في الاثنين، 3 مارس 2025 في 10:12 ص كتب Anna <anna@example.com>:", false},
{"he", "בתאריך יום ב׳, 3 במרץ 2025 ב-10:12 מאת Anna <anna@example.com>:", false},
{"bg", "пн, 3.03.2025 г. в 10:12 ч. Anna <anna@example.com> написа:", false},
{"bn", "সোম, ৩ মার্চ, ২০২৫ এ ১০:১২ AM এ Anna <anna@example.com> লিখেছেন:", false},
{"ca", "El dl., 3 de març 2025 a les 10:12, Anna (<anna@example.com>) va escriure:", false},
{"et", "E, 3. märts 2025 kell 10:12 Anna <anna@example.com> kirjutas:", false},
{"fa", "در تاریخ دوشنبه ۳ مارس ۲۰۲۵ ساعت ۱۰:۱۲ Anna <anna@example.com> نوشت:", false},
{"fil", "Noong Lun, Mar 3, 2025 nang 10:12 AM, si Anna <anna@example.com> ay sumulat:", false},
{"hr", "pon, 3. ožu 2025. u 10:12 Anna <anna@example.com> napisao je:", false},
{"lt", "2025-03-03, pr, 10:12 Anna <anna@example.com> rašė:", false},
{"lv", "pirmd., 2025. g. 3. marts, plkst. 10:12 lietotājs Anna (<anna@example.com>) rakstīja:", false},
{"ms", "Pada Isn, 3 Mac 2025 pada 10:12 PG, Anna <anna@example.com> menulis:", false},
{"sk", "po 3. 3. 2025 o 10:12 Anna <anna@example.com> napísal(a):", false},
{"sl", "pon., 3. mar. 2025 ob 10:12 je Anna <anna@example.com> napisal(a):", false},
{"sr", "пон, 3. мар 2025. у 10:12 Anna <anna@example.com> је написао/ла:", false},
{"sw", "Mnamo Jum 3 Mac 2025 saa 10:12, Anna <anna@example.com> aliandika:", false},
{"ta", "திங்., 3 மார்., 2025, முற்பகல் 10:12 அன்று, Anna <anna@example.com> எழுதியது:", false},
{"ur", "پیر، 3 مارچ، 2025 کو 10:12 AM Anna <anna@example.com> نے لکھا:", false},
}
// Outlook's quoted header block, with that language's labels.
func TestStripQuotedOutlookBlocksByLanguage(t *testing.T) {
cases := map[string][2]string{
@@ -74,6 +91,10 @@ func TestStripQuotedOutlookBlocksByLanguage(t *testing.T) {
"tr": {"Kimden:", "Gönderildi:"}, "ja": {"差出人:", "送信日時:"}, "zh": {"发件人:", "发送时间:"},
"ko": {"보낸 사람:", "보낸 날짜:"}, "id": {"Dari:", "Dikirim:"},
"vi": {"Từ:", "Đã gửi:"}, "ar": {"من:", "تاريخ الإرسال:"}, "he": {"מאת:", "נשלח:"},
"bg": {"От:", "Изпратено:"}, "ca": {"De:", "Enviat el:"}, "et": {"Saatja:", "Saadetud:"},
"fa": {"از:", "ارسال شده:"}, "hr": {"Šalje:", "Poslano:"}, "lt": {"Nuo:", "Išsiųsta:"},
"lv": {"No:", "Nosūtīts:"}, "ms": {"Daripada:", "Dihantar:"}, "sk": {"Od:", "Odoslané:"},
"sl": {"Od:", "Poslano:"}, "sr": {"Од:", "Послато:"},
}
for lang, labels := range cases {
body := "The reply itself.\n\n" + labels[0] + " Anna <anna@example.com>\n" + labels[1] + " 3.3.2025 10:12\nTo: Max" + quotedTail
+15 -7
View File
@@ -21,7 +21,9 @@ use crate::links::{LinkResolver, Resolution};
use crate::producer::Producer;
use crate::redirects::{normalize_host, Lookup, RedirectResolver};
use crate::scanners::{AsnSources, Request, ScannerNetworks};
use crate::unsubscribe::{body_content_type, invalid_token, valid_token, UnsubscribeProxy};
use crate::unsubscribe::{
accept_language, body_content_type, invalid_token, valid_token, UnsubscribeProxy,
};
// 1x1 transparent GIF (43 bytes)
const TRANSPARENT_GIF: &[u8] = &[
@@ -410,12 +412,15 @@ pub async fn unsubscribe_page(
headers: HeaderMap,
) -> Response {
if !valid_token(&token) {
return invalid_token();
return invalid_token(&headers);
}
if let Some(limited) = spend_unsubscribe_budget(&state, peer, &headers).await {
return limited;
}
state.unsubscribe.get(&token).await
state
.unsubscribe
.get(&token, accept_language(&headers))
.await
}
/// POST /unsubscribe/{token} — the confirm button, or a provider's RFC 8058
@@ -428,10 +433,13 @@ pub async fn unsubscribe_submit(
body: Bytes,
) -> Response {
if !valid_token(&token) {
return invalid_token();
return invalid_token(&headers);
}
let content_type = body_content_type(&headers);
state.unsubscribe.post(&token, body, content_type).await
state
.unsubscribe
.post(&token, body, content_type, accept_language(&headers))
.await
}
/// POST /unsubscribe/{token}/resubscribe — the "unsubscribed by mistake" button.
@@ -443,7 +451,7 @@ pub async fn unsubscribe_undo(
body: Bytes,
) -> Response {
if !valid_token(&token) {
return invalid_token();
return invalid_token(&headers);
}
if let Some(limited) = spend_unsubscribe_budget(&state, peer, &headers).await {
return limited;
@@ -451,7 +459,7 @@ pub async fn unsubscribe_undo(
let content_type = body_content_type(&headers);
state
.unsubscribe
.resubscribe(&token, body, content_type)
.resubscribe(&token, body, content_type, accept_language(&headers))
.await
}
+1
View File
@@ -15,6 +15,7 @@ mod producer;
mod redirects;
mod scanners;
mod unsubscribe;
mod unsubscribe_i18n;
use axum::{
extract::DefaultBodyLimit,
+186 -43
View File
@@ -22,6 +22,8 @@ use axum::{
use std::time::Duration;
use tracing::warn;
use crate::unsubscribe_i18n::{PageCopy, COPIES};
/// Longest token accepted before the backend is asked. The self-contained
/// signed token is 96 base64url characters; the ceiling only keeps a megabyte
/// of junk in a path from becoming a backend request.
@@ -35,17 +37,6 @@ const MIN_TOKEN_LEN: usize = 22;
/// Cap on the form body of a confirm or one-click POST, which is a few bytes.
pub const MAX_BODY_BYTES: usize = 16 * 1024;
/// What a recipient sees when the backend cannot be reached. Neutral, like the
/// backend's own pages: the email came from the customer's mailbox, so no
/// brand is named, and replying is a route to the same outcome because reply
/// opt-outs are detected and suppressed too.
const UNAVAILABLE_HTML: &str = r#"<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex">
<title>Try again shortly</title>
<style>body{font-family:system-ui,-apple-system,"Segoe UI",sans-serif;max-width:32rem;margin:4rem auto;padding:0 1.25rem;color:#0f172a;line-height:1.5}
h1{font-size:1.25rem;margin:0 0 .5rem}p{color:#475569;margin:0 0 1.25rem}</style></head>
<body><h1>Try again shortly</h1><p>We could not reach the sender's server just now. Open this link again in a few minutes, or reply to the email and the sender will stop.</p></body></html>"#;
pub struct UnsubscribeProxy {
http: reqwest::Client,
backend_url: String,
@@ -67,8 +58,16 @@ impl UnsubscribeProxy {
}
/// Proxies one opt-out request. `suffix` is the fixed path after the token
/// ("" or "/resubscribe"); `body` is None for GET.
async fn forward(&self, token: &str, suffix: &str, body: Option<(Bytes, String)>) -> Response {
/// ("" or "/resubscribe"); `body` is None for GET. The browser's
/// Accept-Language travels on, so the backend's page is in the
/// recipient's language.
async fn forward(
&self,
token: &str,
suffix: &str,
body: Option<(Bytes, String)>,
accept_language: Option<String>,
) -> Response {
let url = format!("{}/unsubscribe/{}{}", self.backend_url, token, suffix);
let request = match body {
Some((bytes, content_type)) => self
@@ -78,12 +77,16 @@ impl UnsubscribeProxy {
.body(bytes),
None => self.http.get(&url),
};
let request = match &accept_language {
Some(lang) => request.header(reqwest::header::ACCEPT_LANGUAGE, lang),
None => request,
};
let response = match request.send().await {
Ok(response) => response,
Err(e) => {
warn!("unsubscribe proxy: backend unreachable: {}", e);
return unavailable();
return unavailable(accept_language.as_deref());
}
};
@@ -99,7 +102,7 @@ impl UnsubscribeProxy {
Ok(body) => body,
Err(e) => {
warn!("unsubscribe proxy: truncated backend response: {}", e);
return unavailable();
return unavailable(accept_language.as_deref());
}
};
@@ -115,6 +118,7 @@ impl UnsubscribeProxy {
header::HeaderName::from_static("x-robots-tag"),
HeaderValue::from_static("noindex"),
),
(header::VARY, HeaderValue::from_static("Accept-Language")),
],
Bytes::from(body.to_vec()),
)
@@ -122,21 +126,114 @@ impl UnsubscribeProxy {
}
}
fn unavailable() -> Response {
(
/// What a recipient sees when the backend cannot be reached. Neutral, like the
/// backend's own pages: the email came from the customer's mailbox, so no
/// brand is named, and replying is a route to the same outcome because reply
/// opt-outs are detected and suppressed too.
fn unavailable(accept_language: Option<&str>) -> Response {
let (lang, copy) = page_copy(accept_language);
own_page(
StatusCode::SERVICE_UNAVAILABLE,
lang,
copy,
copy.retry_title,
copy.unavailable_body,
)
}
/// One of the two pages the proxy answers itself, as plain as the backend's.
fn own_page(status: StatusCode, lang: &str, copy: &PageCopy, title: &str, body: &str) -> Response {
let dir = if copy.rtl { r#" dir="rtl""# } else { "" };
let html = format!(
r#"<!doctype html><html lang="{lang}"{dir}><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex">
<title>{title}</title>
<style>body{{font-family:system-ui,-apple-system,"Segoe UI",sans-serif;max-width:32rem;margin:4rem auto;padding:0 1.25rem;color:#0f172a;line-height:1.5}}
h1{{font-size:1.25rem;margin:0 0 .5rem}}p{{color:#475569;margin:0 0 1.25rem}}</style></head>
<body><h1>{title}</h1><p>{body}</p></body></html>"#,
title = escape(title),
body = escape(body),
);
(
status,
[
(
header::CONTENT_TYPE,
HeaderValue::from_static("text/html; charset=utf-8"),
),
(header::CACHE_CONTROL, HeaderValue::from_static("no-store")),
(header::VARY, HeaderValue::from_static("Accept-Language")),
],
UNAVAILABLE_HTML,
html,
)
.into_response()
}
fn escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
/// The browser's Accept-Language, when it sent one.
pub fn accept_language(headers: &HeaderMap) -> Option<String> {
headers
.get(header::ACCEPT_LANGUAGE)
.and_then(|v| v.to_str().ok())
.map(str::to_string)
}
/// The most preferred language the pages are written in, English when none
/// is. Mirrors unsubLanguage in the backend, so a recipient reads one
/// language whichever side answers.
pub fn page_copy(accept_language: Option<&str>) -> (&'static str, &'static PageCopy) {
let mut best: Option<(&'static str, &'static PageCopy, f64)> = None;
for part in accept_language.unwrap_or("").split(',') {
let mut fields = part.trim().splitn(2, ';');
let tag = fields.next().unwrap_or("");
let q = match fields.next().map(str::trim) {
Some(params) => match params.strip_prefix("q=") {
Some(v) => match v.parse::<f64>() {
Ok(q) => q,
Err(_) => continue,
},
None => 1.0,
},
None => 1.0,
};
// A qvalue is 0 to 1, and NaN is refused like the backend refuses it.
if !(q > 0.0 && q <= 1.0) || best.is_some_and(|(_, _, b)| q <= b) {
continue;
}
if let Some((code, copy)) = lookup(tag) {
best = Some((code, copy, q));
}
}
match best {
Some((code, copy, _)) => (code, copy),
None => (COPIES[0].0, &COPIES[0].1),
}
}
/// The copy a language tag asks for. Norwegian and Tagalog tags name the
/// language written as nb and fil, and Taiwan, Hong Kong and Macau read
/// Traditional Chinese.
fn lookup(tag: &str) -> Option<(&'static str, &'static PageCopy)> {
let tag = tag.to_ascii_lowercase().replace('_', "-");
let mut parts = tag.split('-');
let primary = parts.next().unwrap_or("");
let code = match primary {
"no" | "nn" => "nb",
"tl" => "fil",
"zh" if parts.any(|p| matches!(p, "hant" | "tw" | "hk" | "mo")) => "zh-Hant",
other => other,
};
COPIES
.iter()
.find(|(c, _)| *c == code)
.map(|(c, copy)| (*c, copy))
}
/// Tokens are base64url without padding. Rejecting anything else here keeps a
/// path-traversal attempt or a spray of junk away from the backend entirely.
pub fn valid_token(token: &str) -> bool {
@@ -157,40 +254,50 @@ pub fn body_content_type(headers: &HeaderMap) -> String {
}
/// What an unknown-shaped token gets: the backend's own wording for an invalid
/// link, so a probe cannot tell the two apart.
pub fn invalid_token() -> Response {
(
/// link, in the recipient's language, so a probe cannot tell the two apart.
pub fn invalid_token(headers: &HeaderMap) -> Response {
let accept = accept_language(headers);
let (lang, copy) = page_copy(accept.as_deref());
own_page(
StatusCode::BAD_REQUEST,
[
(
header::CONTENT_TYPE,
HeaderValue::from_static("text/html; charset=utf-8"),
),
(header::CACHE_CONTROL, HeaderValue::from_static("no-store")),
],
r#"<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex">
<title>This unsubscribe link is invalid</title>
<style>body{font-family:system-ui,-apple-system,"Segoe UI",sans-serif;max-width:32rem;margin:4rem auto;padding:0 1.25rem;color:#0f172a;line-height:1.5}
h1{font-size:1.25rem;margin:0 0 .5rem}p{color:#475569;margin:0 0 1.25rem}</style></head>
<body><h1>This unsubscribe link is invalid</h1><p>Reply to the email instead and the sender will stop.</p></body></html>"#,
lang,
copy,
copy.invalid_title,
copy.reply_body,
)
.into_response()
}
impl UnsubscribeProxy {
pub async fn get(&self, token: &str) -> Response {
self.forward(token, "", None).await
pub async fn get(&self, token: &str, accept_language: Option<String>) -> Response {
self.forward(token, "", None, accept_language).await
}
pub async fn post(&self, token: &str, body: Bytes, content_type: String) -> Response {
self.forward(token, "", Some((body, content_type))).await
}
pub async fn resubscribe(&self, token: &str, body: Bytes, content_type: String) -> Response {
self.forward(token, "/resubscribe", Some((body, content_type)))
pub async fn post(
&self,
token: &str,
body: Bytes,
content_type: String,
accept_language: Option<String>,
) -> Response {
self.forward(token, "", Some((body, content_type)), accept_language)
.await
}
pub async fn resubscribe(
&self,
token: &str,
body: Bytes,
content_type: String,
accept_language: Option<String>,
) -> Response {
self.forward(
token,
"/resubscribe",
Some((body, content_type)),
accept_language,
)
.await
}
}
#[cfg(test)]
@@ -212,4 +319,40 @@ mod tests {
assert!(!valid_token(&format!("{}?x=1", "a".repeat(40))));
assert!(!valid_token(&format!("{}%2f", "a".repeat(40))));
}
#[test]
fn page_language_follows_the_browser() {
let lang = |h: &str| page_copy(Some(h)).0;
assert_eq!(page_copy(None).0, "en");
assert_eq!(lang("de-DE,de;q=0.9,en;q=0.8"), "de");
assert_eq!(lang("en-US,en;q=0.9,de;q=0.8"), "en");
assert_eq!(lang("fr;q=0.5, de;q=0.9"), "de");
assert_eq!(lang("xx-YY, pt-BR;q=0.7"), "pt");
assert_eq!(lang("de;q=0"), "en");
assert_eq!(lang("de;q=abc, it"), "it");
assert_eq!(lang("zh-TW"), "zh-Hant");
assert_eq!(lang("zh-CN"), "zh");
assert_eq!(lang("nn-NO"), "nb");
assert_eq!(lang("tl-PH"), "fil");
assert_eq!(lang("ja-JP;q=0.8, ko-KR;q=0.8"), "ja");
assert_eq!(lang("de;q=0.5,fr;q=NaN"), "de");
assert_eq!(lang("de;q=0.5,fr;q=2"), "de");
assert_eq!(lang("de;q=0.5,fr;q=inf"), "de");
}
#[tokio::test]
async fn own_pages_are_in_the_recipients_language() {
let mut headers = HeaderMap::new();
headers.insert(header::ACCEPT_LANGUAGE, HeaderValue::from_static("ar"));
let response = invalid_token(&headers);
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
let (_, ar) = page_copy(Some("ar"));
assert!(html.contains(r#"<html lang="ar" dir="rtl">"#));
assert!(html.contains(ar.invalid_title));
assert!(html.contains(ar.reply_body));
}
}
+466
View File
@@ -0,0 +1,466 @@
//! The tracking service's own unsubscribe pages, in the recipient's language.
//! The wording matches the backend's (internal/api/handler/unsubscribe_i18n.go),
//! so a probe cannot tell an invalid-shaped token from an invalid one.
/// One language's copy of the two pages the proxy answers itself.
pub struct PageCopy {
pub rtl: bool,
pub invalid_title: &'static str,
pub reply_body: &'static str,
pub retry_title: &'static str,
pub unavailable_body: &'static str,
}
/// The copies by BCP 47 code, English first.
pub const COPIES: &[(&str, PageCopy)] = &[
(
"en",
PageCopy {
rtl: false,
invalid_title: "This unsubscribe link is invalid",
reply_body: "Reply to the email instead and the sender will stop.",
retry_title: "Try again shortly",
unavailable_body: "We could not reach the sender's server just now. Open this link again in a few minutes, or reply to the email and the sender will stop.",
},
),
(
"ar",
PageCopy {
rtl: true,
invalid_title: "رابط إلغاء الاشتراك هذا غير صالح",
reply_body: "يمكنك بدلًا من ذلك الرد على الرسالة وسيتوقف المرسِل عن مراسلتك.",
retry_title: "حاول مرة أخرى بعد قليل",
unavailable_body: "تعذّر علينا الوصول إلى خادم المرسِل الآن. افتح هذا الرابط مرة أخرى بعد بضع دقائق، أو ردّ على الرسالة وسيتوقف المرسِل عن مراسلتك.",
},
),
(
"bg",
PageCopy {
rtl: false,
invalid_title: "Тази връзка за отписване е невалидна",
reply_body: "Вместо това отговорете на имейла и подателят ще спре да ви пише.",
retry_title: "Опитайте отново след малко",
unavailable_body: "В момента не успяхме да се свържем със сървъра на подателя. Отворете тази връзка отново след няколко минути или отговорете на имейла и подателят ще спре да ви пише.",
},
),
(
"bn",
PageCopy {
rtl: false,
invalid_title: "এই আনসাবস্ক্রাইব লিংকটি বৈধ নয়",
reply_body: "এর পরিবর্তে ইমেইলটির উত্তর দিন, তাহলে প্রেরক আপনাকে ইমেইল পাঠানো বন্ধ করবেন।",
retry_title: "কিছুক্ষণ পরে আবার চেষ্টা করুন",
unavailable_body: "এই মুহূর্তে আমরা প্রেরকের সার্ভারে পৌঁছাতে পারিনি। কয়েক মিনিট পরে এই লিংকটি আবার খুলুন, অথবা ইমেইলটির উত্তর দিন, তাহলে প্রেরক আপনাকে ইমেইল পাঠানো বন্ধ করবেন।",
},
),
(
"ca",
PageCopy {
rtl: false,
invalid_title: "Aquest enllaç per cancel·lar la subscripció no és vàlid",
reply_body: "En lloc d'això, responeu al correu i el remitent deixarà d'escriure-us.",
retry_title: "Torneu-ho a provar d'aquí a una estona",
unavailable_body: "Ara mateix no hem pogut contactar amb el servidor del remitent. Torneu a obrir aquest enllaç d'aquí a uns minuts o responeu al correu i el remitent deixarà d'escriure-us.",
},
),
(
"cs",
PageCopy {
rtl: false,
invalid_title: "Tento odkaz k odhlášení je neplatný",
reply_body: "Odpovězte místo toho na e-mail a odesílatel vám přestane psát.",
retry_title: "Zkuste to prosím za chvíli",
unavailable_body: "Server odesílatele se nám teď nepodařilo kontaktovat. Otevřete tento odkaz znovu za několik minut, nebo odpovězte na e-mail a odesílatel vám přestane psát.",
},
),
(
"da",
PageCopy {
rtl: false,
invalid_title: "Afmeldingslinket er ugyldigt",
reply_body: "Svar i stedet på e-mailen, så holder afsenderen op med at skrive til dig.",
retry_title: "Prøv igen om lidt",
unavailable_body: "Vi kunne ikke få kontakt til afsenderens server lige nu. Åbn linket igen om et par minutter, eller svar på e-mailen, så holder afsenderen op med at skrive til dig.",
},
),
(
"de",
PageCopy {
rtl: false,
invalid_title: "Dieser Abmeldelink ist ungültig",
reply_body: "Antworten Sie stattdessen auf die E-Mail, dann schreibt Ihnen der Absender nicht mehr.",
retry_title: "Bitte versuchen Sie es gleich noch einmal",
unavailable_body: "Wir konnten den Server des Absenders gerade nicht erreichen. Öffnen Sie diesen Link in ein paar Minuten erneut oder antworten Sie auf die E-Mail, dann schreibt Ihnen der Absender nicht mehr.",
},
),
(
"el",
PageCopy {
rtl: false,
invalid_title: "Αυτός ο σύνδεσμος κατάργησης εγγραφής δεν είναι έγκυρος",
reply_body: "Εναλλακτικά, απαντήστε στο email και ο αποστολέας θα σταματήσει να σας γράφει.",
retry_title: "Δοκιμάστε ξανά σε λίγο",
unavailable_body: "Δεν ήταν δυνατή η σύνδεση με τον διακομιστή του αποστολέα αυτή τη στιγμή. Ανοίξτε ξανά αυτόν τον σύνδεσμο σε λίγα λεπτά ή απαντήστε στο email και ο αποστολέας θα σταματήσει να σας γράφει.",
},
),
(
"es",
PageCopy {
rtl: false,
invalid_title: "Este enlace para darse de baja no es válido",
reply_body: "En su lugar, responda al correo y el remitente dejará de escribirle.",
retry_title: "Vuelva a intentarlo en unos momentos",
unavailable_body: "No hemos podido conectar con el servidor del remitente en este momento. Abra este enlace de nuevo en unos minutos o responda al correo y el remitente dejará de escribirle.",
},
),
(
"et",
PageCopy {
rtl: false,
invalid_title: "See tellimusest loobumise link on kehtetu",
reply_body: "Vastake selle asemel kirjale ja saatja lõpetab teile kirjutamise.",
retry_title: "Proovige veidi aja pärast uuesti",
unavailable_body: "Me ei saanud praegu saatja serveriga ühendust. Avage see link mõne minuti pärast uuesti või vastake kirjale ja saatja lõpetab teile kirjutamise.",
},
),
(
"fa",
PageCopy {
rtl: true,
invalid_title: "این پیوند لغو اشتراک نامعتبر است",
reply_body: "به‌جای آن به ایمیل پاسخ دهید تا فرستنده دیگر برای شما ایمیل نفرستد.",
retry_title: "کمی بعد دوباره امتحان کنید",
unavailable_body: "در حال حاضر نتوانستیم به سرور فرستنده دسترسی پیدا کنیم. چند دقیقه دیگر دوباره این پیوند را باز کنید، یا به ایمیل پاسخ دهید تا فرستنده دیگر برای شما ایمیل نفرستد.",
},
),
(
"fi",
PageCopy {
rtl: false,
invalid_title: "Tilauksen peruutuslinkki ei ole kelvollinen",
reply_body: "Vastaa sen sijaan sähköpostiin, niin lähettäjä lopettaa viestien lähettämisen.",
retry_title: "Yritä hetken kuluttua uudelleen",
unavailable_body: "Emme saaneet juuri nyt yhteyttä lähettäjän palvelimeen. Avaa linkki uudelleen muutaman minuutin kuluttua tai vastaa sähköpostiin, niin lähettäjä lopettaa viestien lähettämisen.",
},
),
(
"fil",
PageCopy {
rtl: false,
invalid_title: "Hindi valid ang unsubscribe link na ito",
reply_body: "Sa halip, sagutin ang email at titigil na ang nagpadala sa pag-email sa inyo.",
retry_title: "Subukang muli maya-maya",
unavailable_body: "Hindi namin maabot ang server ng nagpadala sa ngayon. Buksan muli ang link na ito pagkalipas ng ilang minuto, o sagutin ang email at titigil na ang nagpadala sa pag-email sa inyo.",
},
),
(
"fr",
PageCopy {
rtl: false,
invalid_title: "Ce lien de désabonnement n'est pas valide",
reply_body: "Répondez plutôt à l'e-mail et l'expéditeur cessera de vous écrire.",
retry_title: "Réessayez dans quelques instants",
unavailable_body: "Nous n'avons pas pu joindre le serveur de l'expéditeur pour le moment. Ouvrez à nouveau ce lien dans quelques minutes, ou répondez à l'e-mail et l'expéditeur cessera de vous écrire.",
},
),
(
"he",
PageCopy {
rtl: true,
invalid_title: "קישור ביטול ההרשמה אינו תקין",
reply_body: "במקום זאת, אפשר להשיב להודעה והשולח יפסיק לשלוח אליך הודעות.",
retry_title: "נא לנסות שוב בעוד כמה רגעים",
unavailable_body: "לא הצלחנו להתחבר לשרת של השולח כרגע. אפשר לפתוח את הקישור שוב בעוד כמה דקות, או להשיב להודעה והשולח יפסיק לשלוח אליך הודעות.",
},
),
(
"hi",
PageCopy {
rtl: false,
invalid_title: "सदस्यता समाप्त करने का यह लिंक अमान्य है",
reply_body: "इसके बजाय ईमेल का जवाब दें, तो प्रेषक आपको ईमेल भेजना बंद कर देगा।",
retry_title: "थोड़ी देर में फिर से प्रयास करें",
unavailable_body: "हम अभी प्रेषक के सर्वर तक नहीं पहुँच सके। कुछ मिनट बाद यह लिंक फिर से खोलें, या ईमेल का जवाब दें, तो प्रेषक आपको ईमेल भेजना बंद कर देगा।",
},
),
(
"hr",
PageCopy {
rtl: false,
invalid_title: "Ova poveznica za odjavu nije valjana",
reply_body: "Umjesto toga odgovorite na e-poruku i pošiljatelj vam više neće pisati.",
retry_title: "Pokušajte ponovno za koji trenutak",
unavailable_body: "Trenutačno se nismo mogli povezati s poslužiteljem pošiljatelja. Otvorite ovu poveznicu ponovno za nekoliko minuta ili odgovorite na e-poruku i pošiljatelj vam više neće pisati.",
},
),
(
"hu",
PageCopy {
rtl: false,
invalid_title: "Ez a leiratkozási hivatkozás érvénytelen",
reply_body: "Válaszoljon inkább az e-mailre, és a feladó nem ír Önnek többet.",
retry_title: "Próbálja újra kicsit később",
unavailable_body: "A feladó szerverét most nem sikerült elérnünk. Nyissa meg újra ezt a hivatkozást néhány perc múlva, vagy válaszoljon az e-mailre, és a feladó nem ír Önnek többet.",
},
),
(
"id",
PageCopy {
rtl: false,
invalid_title: "Tautan berhenti berlangganan ini tidak valid",
reply_body: "Sebagai gantinya, balas email tersebut dan pengirim akan berhenti mengirimi Anda email.",
retry_title: "Coba lagi sebentar lagi",
unavailable_body: "Kami tidak dapat menghubungi server pengirim saat ini. Buka kembali tautan ini dalam beberapa menit, atau balas email tersebut dan pengirim akan berhenti mengirimi Anda email.",
},
),
(
"it",
PageCopy {
rtl: false,
invalid_title: "Questo link di annullamento dell'iscrizione non è valido",
reply_body: "Risponda invece all'email e il mittente smetterà di scriverle.",
retry_title: "Riprovi tra poco",
unavailable_body: "Al momento non è stato possibile raggiungere il server del mittente. Riapra questo link tra qualche minuto, oppure risponda all'email e il mittente smetterà di scriverle.",
},
),
(
"ja",
PageCopy {
rtl: false,
invalid_title: "この配信停止リンクは無効です",
reply_body: "代わりにメールへ返信していただければ、送信者は送信を停止します。",
retry_title: "しばらくしてからもう一度お試しください",
unavailable_body: "現在、送信者のサーバーに接続できませんでした。数分後にこのリンクをもう一度開くか、メールへ返信していただければ、送信者は送信を停止します。",
},
),
(
"ko",
PageCopy {
rtl: false,
invalid_title: "유효하지 않은 수신 거부 링크입니다",
reply_body: "대신 이메일에 회신하시면 발신자가 발송을 중단합니다.",
retry_title: "잠시 후 다시 시도해 주세요",
unavailable_body: "지금은 발신자의 서버에 연결할 수 없습니다. 몇 분 후 이 링크를 다시 열어 보시거나, 이메일에 회신하시면 발신자가 발송을 중단합니다.",
},
),
(
"lt",
PageCopy {
rtl: false,
invalid_title: "Ši prenumeratos atsisakymo nuoroda negalioja",
reply_body: "Vietoj to atsakykite į el. laišką ir siuntėjas jums nebesiųs laiškų.",
retry_title: "Netrukus bandykite dar kartą",
unavailable_body: "Šiuo metu nepavyko susisiekti su siuntėjo serveriu. Atidarykite šią nuorodą dar kartą po kelių minučių arba atsakykite į el. laišką ir siuntėjas jums nebesiųs laiškų.",
},
),
(
"lv",
PageCopy {
rtl: false,
invalid_title: "Šī atteikšanās saite nav derīga",
reply_body: "Tā vietā atbildiet uz e-pastu, un sūtītājs jums vairs nerakstīs.",
retry_title: "Mēģiniet vēlreiz nedaudz vēlāk",
unavailable_body: "Pašlaik neizdevās sazināties ar sūtītāja serveri. Atveriet šo saiti vēlreiz pēc dažām minūtēm vai atbildiet uz e-pastu, un sūtītājs jums vairs nerakstīs.",
},
),
(
"ms",
PageCopy {
rtl: false,
invalid_title: "Pautan berhenti melanggan ini tidak sah",
reply_body: "Sebaliknya, balas e-mel tersebut dan pengirim akan berhenti menghantar e-mel kepada anda.",
retry_title: "Cuba lagi sebentar nanti",
unavailable_body: "Kami tidak dapat menghubungi pelayan pengirim sekarang. Buka semula pautan ini dalam beberapa minit, atau balas e-mel tersebut dan pengirim akan berhenti menghantar e-mel kepada anda.",
},
),
(
"nb",
PageCopy {
rtl: false,
invalid_title: "Denne avmeldingslenken er ugyldig",
reply_body: "Svar på e-posten i stedet, så slutter avsenderen å sende deg e-post.",
retry_title: "Prøv igjen om litt",
unavailable_body: "Vi fikk ikke kontakt med avsenderens server akkurat nå. Åpne denne lenken igjen om noen minutter, eller svar på e-posten, så slutter avsenderen å sende deg e-post.",
},
),
(
"nl",
PageCopy {
rtl: false,
invalid_title: "Deze afmeldlink is ongeldig",
reply_body: "Beantwoord in plaats daarvan de e-mail, dan mailt de afzender u niet meer.",
retry_title: "Probeer het zo opnieuw",
unavailable_body: "We konden de server van de afzender nu niet bereiken. Open deze link over een paar minuten opnieuw, of beantwoord de e-mail, dan mailt de afzender u niet meer.",
},
),
(
"pl",
PageCopy {
rtl: false,
invalid_title: "Ten link do wypisania jest nieprawidłowy",
reply_body: "Zamiast tego wystarczy odpowiedzieć na wiadomość, a nadawca przestanie pisać.",
retry_title: "Prosimy spróbować ponownie za chwilę",
unavailable_body: "Nie udało się teraz połączyć z serwerem nadawcy. Prosimy otworzyć ten link ponownie za kilka minut lub odpowiedzieć na wiadomość, a nadawca przestanie pisać.",
},
),
(
"pt",
PageCopy {
rtl: false,
invalid_title: "Este link de cancelamento é inválido",
reply_body: "Em vez disso, responda ao e-mail e o remetente deixará de enviar mensagens.",
retry_title: "Tente novamente em instantes",
unavailable_body: "Não foi possível contatar o servidor do remetente agora. Abra este link novamente em alguns minutos ou responda ao e-mail e o remetente deixará de enviar mensagens.",
},
),
(
"ro",
PageCopy {
rtl: false,
invalid_title: "Acest link de dezabonare nu este valid",
reply_body: "Răspundeți în schimb la e-mail, iar expeditorul nu vă va mai scrie.",
retry_title: "Încercați din nou în curând",
unavailable_body: "Nu am putut contacta serverul expeditorului acum. Deschideți din nou acest link peste câteva minute sau răspundeți la e-mail, iar expeditorul nu vă va mai scrie.",
},
),
(
"ru",
PageCopy {
rtl: false,
invalid_title: "Эта ссылка для отписки недействительна",
reply_body: "Вместо этого ответьте на письмо, и отправитель перестанет вам писать.",
retry_title: "Попробуйте чуть позже",
unavailable_body: "Сейчас не удалось связаться с сервером отправителя. Откройте эту ссылку снова через несколько минут или ответьте на письмо, и отправитель перестанет вам писать.",
},
),
(
"sk",
PageCopy {
rtl: false,
invalid_title: "Tento odkaz na odhlásenie je neplatný",
reply_body: "Namiesto toho odpovedzte na e-mail a odosielateľ vám prestane písať.",
retry_title: "Skúste to o chvíľu znova",
unavailable_body: "Server odosielateľa sa teraz nepodarilo kontaktovať. Otvorte tento odkaz znova o niekoľko minút alebo odpovedzte na e-mail a odosielateľ vám prestane písať.",
},
),
(
"sl",
PageCopy {
rtl: false,
invalid_title: "Ta povezava za odjavo ni veljavna",
reply_body: "Namesto tega odgovorite na e-sporočilo in pošiljatelj vam ne bo več pisal.",
retry_title: "Poskusite znova čez nekaj trenutkov",
unavailable_body: "Strežnika pošiljatelja trenutno ni bilo mogoče doseči. To povezavo znova odprite čez nekaj minut ali odgovorite na e-sporočilo in pošiljatelj vam ne bo več pisal.",
},
),
(
"sr",
PageCopy {
rtl: false,
invalid_title: "Овај линк за одјаву није важећи",
reply_body: "Уместо тога одговорите на поруку и пошиљалац ће престати да вам пише.",
retry_title: "Покушајте поново ускоро",
unavailable_body: "Тренутно нисмо могли да успоставимо везу са сервером пошиљаоца. Отворите овај линк поново за неколико минута или одговорите на поруку и пошиљалац ће престати да вам пише.",
},
),
(
"sv",
PageCopy {
rtl: false,
invalid_title: "Länken för att avsluta prenumerationen är ogiltig",
reply_body: "Svara på mejlet i stället så slutar avsändaren att skriva till dig.",
retry_title: "Försök igen om en stund",
unavailable_body: "Vi kunde inte nå avsändarens server just nu. Öppna länken igen om några minuter, eller svara på mejlet så slutar avsändaren att skriva till dig.",
},
),
(
"sw",
PageCopy {
rtl: false,
invalid_title: "Kiungo hiki cha kujiondoa si halali",
reply_body: "Badala yake, jibu barua pepe hiyo na mtumaji ataacha kukutumia barua.",
retry_title: "Jaribu tena baada ya muda mfupi",
unavailable_body: "Hatukuweza kufikia seva ya mtumaji kwa sasa. Fungua kiungo hiki tena baada ya dakika chache, au jibu barua pepe hiyo na mtumaji ataacha kukutumia barua.",
},
),
(
"ta",
PageCopy {
rtl: false,
invalid_title: "இந்தக் குழுவிலகல் இணைப்பு செல்லாது",
reply_body: "அதற்குப் பதிலாக மின்னஞ்சலுக்குப் பதிலளியுங்கள், அனுப்புநர் உங்களுக்கு அனுப்புவதை நிறுத்துவார்.",
retry_title: "சிறிது நேரத்தில் மீண்டும் முயலுங்கள்",
unavailable_body: "இப்போது அனுப்புநரின் சேவையகத்தை அணுக முடியவில்லை. சில நிமிடங்களில் இந்த இணைப்பை மீண்டும் திறக்கவும், அல்லது மின்னஞ்சலுக்குப் பதிலளியுங்கள், அனுப்புநர் உங்களுக்கு அனுப்புவதை நிறுத்துவார்.",
},
),
(
"th",
PageCopy {
rtl: false,
invalid_title: "ลิงก์ยกเลิกการรับอีเมลนี้ไม่ถูกต้อง",
reply_body: "โปรดตอบกลับอีเมลแทน แล้วผู้ส่งจะหยุดส่งอีเมลถึงคุณ",
retry_title: "โปรดลองอีกครั้งในอีกสักครู่",
unavailable_body: "ขณะนี้ไม่สามารถเชื่อมต่อกับเซิร์ฟเวอร์ของผู้ส่งได้ โปรดเปิดลิงก์นี้อีกครั้งในอีกไม่กี่นาที หรือตอบกลับอีเมล แล้วผู้ส่งจะหยุดส่งอีเมลถึงคุณ",
},
),
(
"tr",
PageCopy {
rtl: false,
invalid_title: "Bu abonelikten çıkma bağlantısı geçersiz",
reply_body: "Bunun yerine e-postayı yanıtlayın, gönderici size yazmayı bırakacaktır.",
retry_title: "Birazdan tekrar deneyin",
unavailable_body: "Göndericinin sunucusuna şu anda ulaşamadık. Bu bağlantıyı birkaç dakika sonra yeniden açın veya e-postayı yanıtlayın, gönderici size yazmayı bırakacaktır.",
},
),
(
"uk",
PageCopy {
rtl: false,
invalid_title: "Це посилання для відписки недійсне",
reply_body: "Натомість дайте відповідь на лист, і відправник перестане вам писати.",
retry_title: "Спробуйте трохи згодом",
unavailable_body: "Зараз не вдалося зв'язатися із сервером відправника. Відкрийте це посилання знову за кілька хвилин або дайте відповідь на лист, і відправник перестане вам писати.",
},
),
(
"ur",
PageCopy {
rtl: true,
invalid_title: "یہ ان سبسکرائب لنک درست نہیں ہے",
reply_body: "اس کے بجائے ای میل کا جواب دیں، بھیجنے والا آپ کو ای میل بھیجنا بند کر دے گا۔",
retry_title: "تھوڑی دیر میں دوبارہ کوشش کریں",
unavailable_body: "ہم ابھی بھیجنے والے کے سرور تک نہیں پہنچ سکے۔ چند منٹ بعد یہ لنک دوبارہ کھولیں، یا ای میل کا جواب دیں، بھیجنے والا آپ کو ای میل بھیجنا بند کر دے گا۔",
},
),
(
"vi",
PageCopy {
rtl: false,
invalid_title: "Liên kết hủy đăng ký này không hợp lệ",
reply_body: "Thay vào đó, hãy trả lời email và người gửi sẽ ngừng gửi cho bạn.",
retry_title: "Vui lòng thử lại sau ít phút",
unavailable_body: "Hiện chúng tôi chưa thể kết nối tới máy chủ của người gửi. Hãy mở lại liên kết này sau vài phút, hoặc trả lời email và người gửi sẽ ngừng gửi cho bạn.",
},
),
(
"zh",
PageCopy {
rtl: false,
invalid_title: "此退订链接无效",
reply_body: "请直接回复邮件,发件人将不再给您发送邮件。",
retry_title: "请稍后重试",
unavailable_body: "暂时无法连接到发件人的服务器。请几分钟后重新打开此链接,或回复邮件,发件人将不再给您发送邮件。",
},
),
(
"zh-Hant",
PageCopy {
rtl: false,
invalid_title: "此取消訂閱連結無效",
reply_body: "請直接回覆郵件,寄件者就會停止寄信給您。",
retry_title: "請稍後再試",
unavailable_body: "目前無法連線到寄件者的伺服器。請在幾分鐘後重新開啟此連結,或回覆郵件,寄件者就會停止寄信給您。",
},
),
];
@@ -116,7 +116,7 @@ export default function LanguagePicker({ value, onChange }: { value: string[]; o
>
<CheckSquare checked={value.includes(l.code)} />
<span className="truncate">{l.name}</span>
{!OFFLINE_RULE_LANGUAGES.has(l.code) && (
{l.code !== "en" && !OFFLINE_RULE_LANGUAGES.has(l.code) && (
<span className="ml-auto text-[10.5px] text-slate-400">classifier hint only</span>
)}
</button>
@@ -166,8 +166,9 @@ export const MAIL_LANGUAGES: { code: string; name: string }[] = [
// replyclassify.LanguagesWithRules: the languages whose reply formats, away
// messages and dates are read offline. The rest are only named to the classifier.
export const OFFLINE_RULE_LANGUAGES = new Set([
"ar", "cs", "da", "de", "el", "es", "fi", "fr", "he", "hi", "hu", "id", "it", "ja",
"ko", "nb", "nl", "pl", "pt", "ro", "ru", "sv", "th", "tr", "uk", "vi", "zh",
"ar", "bg", "bn", "ca", "cs", "da", "de", "el", "es", "et", "fa", "fi", "fil", "fr",
"he", "hi", "hr", "hu", "id", "it", "ja", "ko", "lt", "lv", "ms", "nb", "nl", "pl",
"pt", "ro", "ru", "sk", "sl", "sr", "sv", "sw", "ta", "th", "tr", "uk", "ur", "vi", "zh",
]);
// Mirrors models.InboxTagLabelName: plain words like the built-in labels,