feat: read German opt-out replies (bitte austragen, keine weiteren E-Mails, löschen Sie meine Daten, a GDPR objection and more) in the reply opt-out check and the lexicon layer, bind meeting-sense words like abmelden and austragen to bitte or the writer, always cut the German quote markers before reading opt-out so the sender's own German opt-out line in an Outlook, GMX/web.de or T-Online quote never opts anyone out, add GMX's date-first header block and the Original-Nachricht divider to the German rules, fold umlauts and line breaks before matching, and mention German replies in the unsubscribe guide

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Skylinerffm97
2026-09-27 10:00:47 +00:00
committed by Claude
co-authored by Claude Opus 5.5
parent b18e11b35b
commit fad04c5efc
5 changed files with 231 additions and 7 deletions
+1 -1
View File
@@ -61,7 +61,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. 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. 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.
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":
+5 -2
View File
@@ -11,6 +11,8 @@ import (
// The offline layers read every reply with the base vocabulary below, and a
// workspace's tagging languages (models.MailLanguageNames codes) add theirs.
// A language nobody chose adds no pattern, so it cannot misread anyone's mail.
// The one exception is the opt-out scan, which always cuts the quote markers
// of the languages its phrases are in (optOutLanguages in lexicon.go).
// langRules is one language's vocabulary, as its mail clients and servers
// write it.
@@ -108,8 +110,9 @@ var cyrillicAttribution = quoteMarker{re: regexp.MustCompile(`(?i)\d{1,2}[./]\d{
var languageRules = map[string]langRules{
"de": {
quote: []quoteMarker{{re: headerBlock(`von`, `gesendet|datum`)}},
separators: []string{`ursprüngliche nachricht`, `weitergeleitete nachricht`},
// GMX and web.de put the date first: "Gesendet: ... Von: ...".
quote: []quoteMarker{{re: headerBlock(`von`, `gesendet|datum`)}, {re: headerBlock(`gesendet`, `von`)}},
separators: []string{`ursprüngliche nachricht`, `original-nachricht`, `weitergeleitete nachricht`},
},
"fr": {
quote: []quoteMarker{{re: headerBlock(`de ?`, `envoyé ?|date ?`)}},
+125 -4
View File
@@ -2,6 +2,7 @@ package replyclassify
import (
"regexp"
"slices"
"strings"
)
@@ -16,7 +17,7 @@ import (
// 2. Clear interest phrases => positive.
// 3. Clear rejection phrases => negative.
func classifyLexicon(in Input) (Result, bool) {
text := quoteFolder.Replace(strings.ToLower(strings.TrimSpace(in.Subject + "\n" + StripQuoted(in.BodyText, in.Languages...))))
text := quoteFolder.Replace(strings.ToLower(strings.TrimSpace(in.Subject + "\n" + StripQuoted(in.BodyText, slices.Concat(in.Languages, optOutLanguages)...))))
if text == "" {
return Result{}, false
}
@@ -69,8 +70,125 @@ var unsubscribeKeywords = []string{
"delete my details",
"delete my data",
"delete my information",
// German, written as accentFolder leaves it ("löschen" reads "loeschen").
// "abmelden" and "austragen" also cancel a meeting or a course, so they
// count only next to "bitte" or the writer themselves.
"bitte austragen",
"austragen bitte",
"mich austragen",
"uns austragen",
"mich auszutragen",
"uns auszutragen",
"um austragung",
"tragen sie mich aus",
"tragen sie uns aus",
"tragen sie mich bitte aus",
"tragen sie uns bitte aus",
"trag mich aus",
"trag mich bitte aus",
"tragt mich aus",
"tragt uns aus",
"bitte abmelden",
"abmelden bitte",
"mich abmelden",
"uns abmelden",
"um abmeldung",
"bitte melden sie mich ab",
"melden sie mich bitte ab",
"bitte melden sie uns ab",
"melden sie uns bitte ab",
"bitte abbestellen",
"mich aus dem verteiler",
"mich aus ihrem verteiler",
"mich aus eurem verteiler",
"mich aus deinem verteiler",
"uns aus dem verteiler",
"uns aus ihrem verteiler",
"aus dem verteiler nehmen",
"aus ihrem verteiler nehmen",
"aus dem verteiler loeschen",
"aus ihrem verteiler loeschen",
"entfernen sie mich",
"entfernen sie uns",
"entfernen sie meine daten",
"entfernen sie meine adresse",
"entfernen sie meine e-mail",
"entfern mich",
"streichen sie mich aus",
"streichen sie mich von",
"streichen sie uns aus",
"streichen sie uns von",
"von der liste streichen",
"von ihrer liste streichen",
"nehmen sie mich aus",
"nehmen sie mich von",
"nehmen sie mich raus",
"nehmen sie uns aus",
"nimm mich raus",
"keine weiteren e-mails",
"keine weiteren emails",
"keine weiteren mails",
"keine weitere e-mail",
"keine weitere email",
"keine weitere mail",
"keine e-mails mehr",
"keine emails mehr",
"keine mails mehr",
"keine nachrichten mehr",
"bitte keine werbung mehr",
"mir keine werbung mehr",
"uns keine werbung mehr",
"keine werbemails",
"stoppen sie die zusendung",
"stoppen sie die zusendungen",
"mich nicht mehr kontaktieren",
"uns nicht mehr kontaktieren",
"kontaktieren sie mich nicht mehr",
"kontaktieren sie uns nicht mehr",
"bitte nicht mehr kontaktieren",
"bitte nicht mehr anschreiben",
"nicht mehr angeschrieben",
"schreiben sie mich nicht mehr an",
"schreiben sie mir nicht mehr",
"schreiben sie mir bitte nicht mehr",
"schreib mir nicht mehr",
"schreib mir bitte nicht mehr",
"von weiteren kontaktaufnahmen",
"keine weiteren kontaktaufnahmen",
"von einer weiteren kontaktaufnahme",
"keine weitere kontaktaufnahme",
"von jeglicher kontaktaufnahme",
"unterlassen sie jegliche",
"unterlassen sie kuenftig",
"unterlassen sie weitere",
"loeschen sie meine daten",
"loeschen sie bitte meine daten",
"loeschen sie meine adresse",
"loeschen sie meine e-mail",
"loeschen sie meine kontaktdaten",
"loeschen sie bitte meine adresse",
"loeschen sie bitte meine e-mail",
"loeschen sie mich",
"meine daten loeschen",
"meine daten zu loeschen",
// A GDPR Art. 21 objection. The bare verb also means "I disagree".
"widerspreche hiermit",
"widerspreche der verarbeitung",
"widerspreche der nutzung",
"widerspreche der verwendung",
"widerspreche ich der verarbeitung",
"widerspreche ich der nutzung",
"widerspreche ich der verwendung",
}
// 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)
// compileWordPatterns anchors each phrase on word boundaries; "don't" and
@@ -84,11 +202,14 @@ func compileWordPatterns(phrases []string) []*regexp.Regexp {
}
// quoteFolder folds the typographic apostrophes mail clients substitute while
// typing, so "don’t email me" matches the straight-quote phrase.
// 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.
var quoteFolder = strings.NewReplacer("\u2019", "'", "\u2018", "'", "\u02bc", "'", "\u00b4", "'", "`", "'")
func matchesOptOut(lowerText string) bool {
lowerText = quoteFolder.Replace(lowerText)
// A plain-text reply wraps a phrase across lines; the phrases hold one
// space between words.
lowerText = strings.Join(strings.Fields(accentFolder.Replace(lowerText)), " ")
for _, re := range optOutPatterns {
if re.MatchString(lowerText) {
return true
@@ -109,7 +230,7 @@ func MentionsOptOut(subject, body string) bool {
// the quoted original carries the sender's own opt-out line, so matching the
// whole body would opt out everyone who replies.
func IsOptOut(subject, body string) bool {
text := strings.ToLower(strings.TrimSpace(subject + "\n" + StripQuoted(body)))
text := strings.ToLower(strings.TrimSpace(subject + "\n" + StripQuoted(body, optOutLanguages...)))
if text == "" {
return false
}
+88
View File
@@ -35,3 +35,91 @@ func TestLexiconIgnoresQuotedHistory(t *testing.T) {
t.Fatalf("got %+v ok=%v, want positive", r, ok)
}
}
// German opt-out wording is read on every reply, whatever languages the
// workspace tags in, and never from the quoted original: a German campaign
// step carries its own "bitte austragen" line.
func TestIsOptOutGerman(t *testing.T) {
const ownLine = "Kein Interesse? Antworten Sie einfach mit \u201ebitte austragen\u201c."
optOut := []string{
"Bitte austragen.",
"Bitte tragen Sie mich aus Ihrem Verteiler aus.",
"Bitte tragen Sie uns aus.",
"Trag mich bitte aus.",
"Ich möchte mich abmelden.",
"Bitte melden Sie mich ab.",
"Bitte um Abmeldung.",
"Ich möchte keine weiteren E-Mails von Ihnen erhalten.",
"Keine weitere Mail, danke.",
"Bitte entfernen Sie meine Adresse aus Ihrer Datenbank.",
"Bitte nimm mich raus.",
"Bitte löschen Sie meine Daten.",
"Bitte loeschen Sie meine Daten.",
"Löschen Sie bitte meine Daten.",
"Schreib mir bitte nicht mehr.",
"Bitte schreiben Sie mich nicht mehr an.",
"Bitte sehen Sie von weiteren Kontaktaufnahmen ab.",
"Unterlassen Sie künftig jegliche Kontaktaufnahme.",
"Keine Werbemails mehr bitte.",
"Bitte stoppen Sie die Zusendungen.",
"Hiermit widerspreche ich der Verwendung meiner Daten zu Werbezwecken.",
"Ich widerspreche hiermit der Verarbeitung meiner personenbezogenen Daten.",
"BITTE MICH NICHT MEHR KONTAKTIEREN",
"Bitte löschen Sie mich aus Ihrer Datenbank.",
"Keine weitere Kontaktaufnahme erwünscht.",
"Bitte aus dem Verteiler nehmen.",
"Bitte von der Liste streichen.",
"Ich möchte keine weiteren\r\nE-Mails erhalten.",
"Bitte austragen.\r\n\r\nVon: Marc <marc@example.com>\r\nGesendet: Montag, 28. September 2026 09:12\r\n\r\n" + ownLine,
}
for _, body := range optOut {
if !IsOptOut("", body) {
t.Errorf("IsOptOut(%q) = false, want true", body)
}
}
if !IsOptOut("Abmelden bitte", "") {
t.Error("an opt-out subject with an empty body was missed")
}
notOptOut := map[string]string{
"interest": "Klingt spannend, rufen Sie mich gerne an.",
"meeting": "Ich muss mich leider vom Termin am Freitag abmelden, passt Montag?",
"calendar": "Ich musste den Termin leider wieder austragen.",
"participle": "Ich bin heute abgemeldet, melde mich morgen.",
"consent": "Mit der Verwendung meiner Daten für die Terminbuchung bin ich einverstanden.",
"disagree": "Da widerspreche ich Ihnen, unser Vertrieb läuft gut.",
"no ads": "Wir machen aktuell keine Werbung, aber gerne ein Termin.",
"colleague": "Herrn Müller brauchen Sie nicht mehr kontaktieren, ich bin jetzt zuständig.",
"forwarded": "Ich habe Ihre Mail an die Kollegen aus dem Verteiler weitergeleitet.",
"contact person": "Für weitere Kontaktaufnahmen wenden Sie sich bitte an Frau Klein.",
"from monday": "Ich meld mich ab Montag wieder, dann gerne ein Termin.",
"pilot": "Melden Sie mich ab Oktober für den Pilot an.",
"call instead": "Bitte kontaktieren Sie mich nicht per Mail, sondern rufen Sie an.",
"not before": "Kontaktieren Sie uns nicht vor Oktober, dann gerne.",
"quote item": "Streichen Sie uns bitte Position 3 aus dem Angebot.",
"print ads": "Wir machen keine Werbung mehr in Print, aber online gerne.",
"no mail yet": "Ich habe bisher keine weiteren Nachrichten erhalten.",
"disclaimer": "Gerne, passt.\n\nDiese E-Mail enthält vertrauliche Informationen. Wenn Sie nicht der richtige Adressat sind, informieren Sie bitte sofort den Absender und löschen Sie diese E-Mail.",
"gmail quote": "Klingt gut, melden Sie sich.\n\nAm Mo., 28. Sept. 2026 um 09:12 Uhr schrieb Marc <marc@example.com>:\n> " + ownLine,
"outlook quote": "Klingt interessant, rufen Sie mich an.\r\n\r\nVon: Marc <marc@example.com>\r\nGesendet: Montag, 28. September 2026 09:12\r\nAn: Jan <jan@example.de>\r\nBetreff: Kurze Frage\r\n\r\n" + ownLine,
"outlook divider": "Passt, gerne.\r\n\r\n-----Ursprüngliche Nachricht-----\r\n" + ownLine,
"gmx quote": "Klingt interessant.\r\n\r\nGesendet: Montag, 28. September 2026 um 09:12 Uhr\r\nVon: \"Marc\" <marc@example.com>\r\nAn: jan@example.de\r\nBetreff: Kurze Frage\r\n\r\n" + ownLine,
"t-online quote": "Gerne, rufen Sie an.\r\n\r\n-------- Original-Nachricht --------\r\n" + ownLine,
}
for name, body := range notOptOut {
if IsOptOut("", body) {
t.Errorf("%s: IsOptOut(%q) = true, want false", name, body)
}
}
}
func TestLexiconGermanOptOut(t *testing.T) {
r, ok := classifyLexicon(Input{Subject: "AW: Kurze Frage", BodyText: "Bitte austragen, danke."})
if !ok || r.Class != ClassUnsubscribe {
t.Fatalf("got %+v ok=%v, want unsubscribe", r, ok)
}
r, ok = classifyLexicon(Input{BodyText: "Sounds good, let's talk.\r\n\r\nVon: Marc <marc@example.com>\r\nGesendet: Montag, 28. September 2026 09:12\r\n\r\nKein Interesse? Einfach \u201ebitte austragen\u201c antworten."})
if !ok || r.Class != ClassPositive {
t.Fatalf("got %+v ok=%v, want positive", r, ok)
}
}
@@ -86,12 +86,24 @@ func TestStripQuotedOutlookBlocksByLanguage(t *testing.T) {
}
}
// GMX and web.de write the date above the sender.
func TestStripQuotedGermanDateFirstBlock(t *testing.T) {
body := "The reply itself.\n\nGesendet: Montag, 03. März 2025 um 10:12 Uhr\nVon: \"Anna\" <anna@example.com>\nAn: Max" + quotedTail
if got := StripQuoted(body, "de"); got != "The reply itself." {
t.Errorf("StripQuoted = %q", got)
}
if StripQuoted(body) == "The reply itself." {
t.Error("read without the language")
}
}
func TestStripQuotedSeparatorsByLanguage(t *testing.T) {
for sep, lang := range map[string]string{
"-----Original Message-----": "", "-----Message d'origine-----": "fr", "-----Mensaje original-----": "es",
"---------- Mensagem encaminhada ---------": "pt", "-----Oorspronkelijk bericht-----": "nl",
"-----Исходное сообщение-----": "ru", "-----原始邮件-----": "zh", "---------- 転送されたメッセージ ---------": "ja",
"-----Oryginalna wiadomość-----": "pl", "-----Αρχικό μήνυμα-----": "el",
"-------- Original-Nachricht --------": "de",
} {
body := "The reply itself.\n\n" + sep + "\nAnna" + quotedTail
if got := StripQuoted(body, lang); got != "The reply itself." {