"""Anonymise parcel mails: for testers (sample mails for an issue) and for the fixtures. Runs locally with the Python standard library only (Python 3.9 or newer), as a single file, without network access. Tester mode, the default (messages in German, --help lists the options): python3 anonymize_mail.py [--zip] [--out DIR] [--ohne-angaben] ... Fixture mode, for the developer and only with the explicit flag (see "fixture mode" below): python3 anonymize_mail.py --fixtures SRC_DIR DST_DIR POSTCODE CITY FIRSTNAME LASTNAME [MAILDOMAIN ...] The replaced values are never printed. Exit codes: 0 done, 1 aborted or failed, 2 wrong call. What each rule replaces is told by the tests (tests/test_anonymize_*.py); the instructions for testers are in docs/hilfe.md ("Beispielmails einreichen"). """ import email import email.utils import glob import hashlib import html import os import re import secrets import sys import unicodedata import zipfile from email import policy from email.message import EmailMessage from html.parser import HTMLParser BLOCK = {"br", "p", "div", "tr", "li", "h1", "h2", "h3", "h4", "table", "td", "th"} SKIP = {"style", "script", "head", "title"} class _Text(HTMLParser): def __init__(self): super().__init__() self.out, self.skip = [], 0 def handle_starttag(self, tag, attrs): if tag in SKIP: self.skip += 1 elif tag in BLOCK: self.out.append("\n") def handle_endtag(self, tag): if tag in SKIP: self.skip = max(self.skip - 1, 0) elif tag in BLOCK: self.out.append("\n") def handle_data(self, data): if not self.skip: self.out.append(data) def html_to_text(markup): parser = _Text() parser.feed(markup) lines = (re.sub(r"[ \t\xa0]+", " ", line).strip() for line in "".join(parser.out).splitlines()) return "\n".join(line for line in lines if line) def as_html(text): return "\n" + "\n".join( f"

{html.escape(line)}

" for line in text.splitlines() if line.strip() ) + "\n\n" def load(path): with open(path, "rb") as handle: return email.message_from_binary_file(handle, policy=policy.default) # Apple's relay "E-Mail-Adresse verbergen": "_at___@privaterelay…". # The two ids belong to the recipient. RELAY_HOST = "privaterelay.appleid.com" _RELAY_FILL = "0a1b2c3d4e5f6g7h8i9j" # Where the sender's domain ends in a relay address (its dots are underscores there). _RELAY_TLDS = frozenset( "com de net org eu at ch info io co uk fr it nl es pl be dk se no fi cz us ca au jp cn " "shop store app online biz".split() ) def _relay_parts(address): """(sender's local part, labels of its domain, what follows them) of an address of Apple's relay; None for any other address and for one without a sender in it. The domain ends with its top-level domain ("…_com", "…_co_uk"): everything behind it belongs to the recipient, whatever it looks like (two ids as a rule, but nobody promises their number or their characters). Without a top-level domain the script knows, the last two parts are taken for the ids and at least two labels stay. """ local, _, host = address.lower().rpartition("@") name, at, rest = local.rpartition("_at_") parts = rest.split("_") if host != RELAY_HOST or not at or not name or len(parts) < 2: return None end = next((n for n in range(1, len(parts)) if parts[n] in _RELAY_TLDS), None) if end is None: end = max(len(parts) - 3, 1) else: while end + 1 < len(parts) and parts[end + 1] in _RELAY_TLDS: end += 1 return name, parts[:end + 1], parts[end + 1:] def relay_address(address): """An address of Apple's relay with invented ids of the same length; any other as it is. Fails closed: at the relay's host everything behind the sender's domain is replaced, and an address without a sender in it becomes a placeholder as a whole. """ if address.rpartition("@")[2].lower() != RELAY_HOST: return address found = _relay_parts(address) if found is None: return f"absender@{RELAY_HOST}" name, domain, ids = found invented = [(_RELAY_FILL * (len(part) // len(_RELAY_FILL) + 1))[:len(part)] for part in ids] return f"{name}_at_{'_'.join(domain + invented)}@{RELAY_HOST}" def relay_original(address): """The address a relay address stands for, "" if it has not that shape. Dots of the original are underscores there, so the result is only good for comparing.""" found = _relay_parts(address) return f"{found[0]}@{'.'.join(found[1])}" if found else "" # ----- Eventim (both modes) ----- # Senders: every sender at eventim.de and eventimpresents.com, also passed on by Apple's relay. # Nothing of the buyer, the purchase or the event stays. # # Order sender (bestellstatus@eventim.de: order confirmation and shipping notice): # - Only lines the script knows are written, every other line is dropped: headings, Eventim's # own sentences, the labels of the order ("Bestellnummer:", "Liefermethode:" with its # value, ...). # - Replaced in them: the greeting, the address blocks, every date (also the Date header: # invented days, one after the other from Mon, 5 Jan 2026), the payment method, prices, the # event (name, day, venue, organiser: invented ones, the same name for the same event) and # its tickets. # - Order numbers become 99…01, 99…02, … in order of appearance. # - The text ends with the heading "HINWEISE": hints, legal texts and the imprint are cut. # # Every other Eventim mail (newsletter, ticket alarm, customer service, a club's ticket shop): # - Only the sender address is kept, with the club's name in "kundenservice-@" replaced. # - The display name is "EVENTIM", subject and text are one fixed line each. # # Tester mode: also a mail forwarded by hand; its quoted header block stays in front, with # its sender, subject and day replaced the same way. The general rules run afterwards. EVT_DOMAINS = ("eventim.de", "eventimpresents.com") EVT_ORDER_SENDER = "bestellstatus@eventim.de" def is_eventim(address): """A sender at eventim.de or eventimpresents.com, also passed on by Apple's relay.""" host = (relay_original(address) or address).rpartition("@")[2] return any(host == domain or host.endswith("." + domain) for domain in EVT_DOMAINS) _EVT_EVENTS = ( "Beispielband – Tour 2026", "Musterfestival 2026 – Wochenendticket", "Beispielorchester – Neujahrskonzert", "Musterensemble – Das Beispielstück", "Beispielkünstlerin – Live 2026", ) _EVT_VENUE = "Ort: Musterhalle, Musterstraße 1, 12345 Musterstadt" _EVT_ORGANISER = ( "Veranstalter: Beispiel Konzert GmbH, Musterstraße 2, 12345 Musterstadt, Deutschland" ) _EVT_DAY = "Datum: Sa., 05.12.2026, 20:00" _EVT_TICKET = "Stehplatz, Normalpreis: € 1,99" _EVT_ADDRESS = ("Max Mustermann", "Musterstraße 1", "12345 Musterstadt", "Deutschland") _EVT_OTHER_SUBJECT = "Neuigkeiten von EVENTIM" _EVT_OTHER_TEXT = "[Inhalt entfernt: keine Bestellung mit Postversand]\n" _EVT_END = "HINWEISE" _EVT_HEADINGS = { "BESTELLBESTÄTIGUNG", "VERSANDBENACHRICHTIGUNG", "DEINE BESTELLUNG", "EINZELHEITEN DEINER BESTELLUNG", "GESAMT", } # Eventim's own sentences, told by how they begin; dates and numbers in them are replaced. _EVT_SENTENCES = re.compile( r"Vielen Dank für deine Bestellung|vielen Dank für deinen Einkauf vom" r"|Vielen Dank für deinen Einkauf bei EVENTIM|Klicke auf einen der folgenden Punkte" r"|Lieferadresse: https?://|Lieferzeiten:|Versandstatus:|Für weitere Themen" r"|Diese E-Mail ist deine Buchungsbestätigung|wir haben heute deine Bestellung Nr\." r"|Wir wünschen dir viel Spaß|Dein EVENTIM-Kundenservice" r"|Dies ist eine automatisch generierte|Die Lieferanschrift für diese Bestellung" r"|Die Zustellung erfolgt spätestens innerhalb|Der Versand der Tickets erfolgt zwischen" r"|Das ticketdirect kann|Voraussichtliche Ankunft:" ) _EVT_ADDRESS_LABELS = ("Lieferadresse:", "Rechnungsadresse:") # Labels of the order: (label, value written instead; None keeps the value, with its # numbers and dates replaced like everywhere). _EVT_LABELS = ( ("Bestellnummer:", None), ("Bestelldatum:", None), ("Gesamtwert:", None), ("Rechnung als PDF herunterladen:", None), ("Liefermethode:", None), ("Zahlungsart:", "Rechnung"), ("Zwischensumme:", None), ("Versandkosten:", None), ("Gesamtsumme inkl. MwSt.:", None), ) _EVT_GREETING = re.compile(r"(Hallo|Hi|Liebe[rs]?|Guten Tag)\b[^\n]*,") _EVT_SEPARATOR = re.compile(r"[-=]{5,}") _EVT_PRICED = re.compile(r"[^:\n]{1,80}:\s*(?:\d+ x )?€ ?\d") _EVT_PRICE = re.compile(r"€ ?\d[\d.]*,\d\d") _EVT_DATE = re.compile(r"(?\]\)\"]+") _EVT_ORDER_SUBJECT = re.compile( r"(?i)(Deine EVENTIM-Bestellung:\s*)(.*?)((?:\s+und\s+\d+\s+mehr)?\s+-\s+Bestellnummer\s+)(\d+)" ) _EVT_SHIPPED_SUBJECT = re.compile( r"(?i)(.*Versandbenachrichtigung\s+-\s+Deine Bestellung Nr\.?\s*)(\d+)" ) _EVT_CLUB = re.compile(r"^((?:noreply_)?kundenservice)-[^@_]+") evt_numbers, evt_events = {}, {} def _evt_number(match): real = match.group(0) if real in evt_numbers.values(): return real # an invented number met again if real not in evt_numbers: evt_numbers[real] = "9" * (len(real) - 2) + f"{len(evt_numbers) + 1:02d}" return evt_numbers[real] def _evt_event(real): """An invented event name for a real one (the same for the same event).""" key = re.sub(r"\W+", "", real.lower())[:12] if key not in evt_events: evt_events[key] = _EVT_EVENTS[len(evt_events) % len(_EVT_EVENTS)] return evt_events[key] def _evt_values(line): """Dates, prices, links and order numbers of a line that is kept.""" line = _EVT_URL.sub(lambda m: re.match(r"https?://[^/\s]+", m.group(0)).group(0) + "/…", line) line = _EVT_SHIP_WINDOW.sub("zwischen dem 1. Juni und 5. Juni 2026.", line) line = _EVT_DATE.sub("05.01.2026", line) line = _EVT_PRICE.sub("€ 1,99", line) return _EVT_NUMBER.sub(_evt_number, line) def evt_address(address): """The sender address of an Eventim mail without a club's name in it.""" return _EVT_CLUB.sub("\\1-musterverein", relay_address(address)) def evt_date(count): """The invented Date header of the ``count``-th Eventim mail of a run (1, 2, …).""" day = email.utils.parsedate_to_datetime("Mon, 05 Jan 2026 10:00:00 +0100") return email.utils.format_datetime(day.replace(day=day.day + (count - 1) % 20)) def evt_quoted_day(count): """The day of ``evt_date`` as a mail program quotes it above a forwarded mail.""" day = email.utils.parsedate_to_datetime(evt_date(count)) return f"{day.day}. Januar {day.year} {day:%H:%M}" def scrub_eventim_subject(subject): """The subject of a mail of Eventim's order sender; an unknown one is not kept.""" subject = " ".join(subject.split()) if found := _EVT_ORDER_SUBJECT.fullmatch(subject): head, event, tail, number = found.groups() return head + _evt_event(event) + tail + _EVT_NUMBER.sub(_evt_number, number) if found := _EVT_SHIPPED_SUBJECT.fullmatch(subject): return found.group(1) + _EVT_NUMBER.sub(_evt_number, found.group(2)) return _EVT_OTHER_SUBJECT def scrub_eventim(t): """A mail of Eventim's order sender, the text: only lines the script knows are kept (see the module docstring); runs before scrub().""" lines = [re.sub(r"[ \t\xa0]+", " ", line).strip() for line in t.replace("\r\n", "\n").split("\n")] lines = [line for line in lines if line] out, index = [], 0 while index < len(lines): line = lines[index] below = lines[index + 1] if index + 1 < len(lines) else "" index += 1 if line == _EVT_END: out.append(line) break if line in _EVT_HEADINGS or _EVT_SEPARATOR.fullmatch(line): out.append(line) elif _EVT_GREETING.fullmatch(line): out.append("Hallo Max,") elif line in _EVT_ADDRESS_LABELS: # name, street, postcode and place, country: up to the next line the script # knows (never more than six lines) block = 0 while index < len(lines) and block < 6 and not _evt_known(lines[index]): index += 1 block += 1 out += [line, *_EVT_ADDRESS] elif _EVT_SENTENCES.match(line): out.append(_evt_values(line)) elif label := next((item for item in _EVT_LABELS if line.startswith(item[0])), None): name, fixed = label value = line[len(name):].strip() if not value and below and not _evt_known(below): # (older mails: the value stands on the next line) index += 1 out += [name, fixed or _evt_values(below)] else: out.append(f"{name} {fixed or _evt_values(value)}".rstrip()) elif line.startswith("Datum:"): out.append(_EVT_DAY) elif line.startswith("Ort:"): out.append(_EVT_VENUE) elif line.startswith("Veranstalter:"): out.append(_EVT_ORGANISER) elif below.startswith("Datum:"): out.append(_evt_event(line)) elif _EVT_PRICED.match(line): out.append(_EVT_TICKET) # (every other line is dropped: seats, notes, whatever a new layout brings) return "\n".join(out) + "\n" def _evt_known(line): """A line of an Eventim mail that is none of an address block.""" return bool( line == _EVT_END or line in _EVT_HEADINGS or line in _EVT_ADDRESS_LABELS or _EVT_SEPARATOR.fullmatch(line) or _EVT_SENTENCES.match(line) or any(line.startswith(name) for name, _ in _EVT_LABELS) ) # ----- tester mode: anonymise own mails before they are attached to a GitHub issue ----- _T_USAGE = ( 'usage: anonymize_mail.py [--zip] [--out ORDNER] [--ohne-angaben]\n' ' [--name "Vorname Nachname"] [--street "Straße Hausnummer"] [--postcode PLZ]\n' ' [--city ORT] [--phone TELEFON] [--email MAILADRESSE] [--salt DATEI]\n' ' anonymize_mail.py --fixtures SRC_DIR DST_DIR POSTCODE CITY FIRSTNAME LASTNAME' ' [MAILDOMAIN ...]' ) _T_HELP = """ Macht aus gespeicherten Paketmails (.eml) anonymisierte Beispielmails für ein GitHub-Issue. eine Mail oder ein Ordner mit .eml-Dateien --zip zusätzlich paket-tracker-beispiele.zip schreiben --out ORDNER Zielordner (Standard: "anonymisiert" neben den Mails) --name, --street, --postcode, --city, --phone, --email die eigenen Angaben, die überall ersetzt werden; mehrere Werte mit ; trennen. Fehlt eine Angabe, fragt das Skript im Terminal danach (leer lassen = überspringen). --ohne-angaben ausdrücklich ohne eigene Angaben arbeiten: Das Skript fragt nicht und bereinigt nur allgemein. Namen und Orte können dann stehen bleiben. Ohne diese Option braucht das Skript mindestens eine Angabe. --salt DATEI wiederholbare Ergebnisse: Fehlt DATEI, legt das Skript sie mit einem zufälligen Salt an (ein selbst ausgedachter ließe sich erraten und wird abgelehnt). Erfundene Nummern und Dateinamen hängen dann nur an der Mail, nicht an ihrer Stelle im Lauf. Eine Mail ergibt so in jedem Lauf dieselbe Datei, solange das Skript aus den anderen Mails nichts Neues lernt (bei Eventim zählen Tag und Bestellnummer weiter die Mails des Laufs). Sie heißt __.eml und ersetzt ihr Ergebnis aus einem früheren Lauf; andere frühere Ergebnisse mit Salt bleiben liegen und werden am Ende genannt. Die DATEI nicht weitergeben und nicht in den Zielordner legen: Mit ihr lassen sich die erfundenen Nummern zurückrechnen. Ersetzt werden außerdem alle Mail-Adressen außer dem Absender, Telefonnummern, Links (nur der Servername bleibt), Sendungs-, Bestell- und andere lange Nummern (gleiche Form, erfundene Ziffern), Ablageorte, Anreden, Adressblöcke, Zeilen mit Postleitzahl und Ort, Namen von Nachbarn, Empfängern und privaten Absendern sowie Nummer und Straße einer Filiale oder Packstation. Technische Kopfzeilen und Anhänge entfallen. Frühere Ergebnisse im Zielordner (NNN_*.eml des Skripts und das ZIP) werden vorher gelöscht. Das Skript läuft nur lokal und baut keine Netzwerkverbindung auf. Die letzte Zeile der Aufruf-Übersicht ist der Entwickler-Aufruf für Test-Fixtures. """ _T_OPTIONS = { "name": "Vor- und Nachname", "street": "Straße und Hausnummer", "postcode": "Postleitzahl", "city": "Ort", "phone": "Telefonnummer", "email": "Mail-Adresse", } _T_BARE = "--ohne-angaben" # A salt file as the script writes it: 32 random bytes. One a tester thought of could be found # by trying, from a number in a mail whose original is public (a trade licence number). _T_SALT = re.compile(r"[0-9a-f]{64}") _T_ZIP = "paket-tracker-beispiele.zip" _T_MAIL = "max@example.org" _T_PHONE = "+49 000 0000000" # A sender at one of these providers is a person (a forwarded mail), never a shop or carrier. _T_FREEMAIL = { "gmail.com", "googlemail.com", "gmx.de", "gmx.net", "gmx.at", "gmx.ch", "web.de", "t-online.de", "outlook.com", "outlook.de", "hotmail.com", "hotmail.de", "live.com", "live.de", "yahoo.com", "yahoo.de", "icloud.com", "me.com", "mac.com", "posteo.de", "posteo.net", "mailbox.org", "freenet.de", "aol.com", "proton.me", "protonmail.com", "arcor.de", "online.de", "mail.de", "email.de", "tutanota.com", "tuta.io", "vodafone.de", } # Where a given or learnt word may begin and end: no letter before / behind it, or the case # changes there. Some senders write the text part beside their HTML without its line breaks # ("Herr MeierIhre Bestellung", "Jonas MeierLindenweg 7", in capitals "MEIERIhre": the last # capital starts the next word); inside an ordinary word the case never changes like that. _T_LOWER, _T_UPPER = ( "".join(char for char in map(chr, range(0x250)) if kind(char)) for kind in (str.islower, str.isupper) ) _T_CASE_CHANGE = ( rf"(?-i:(?<=[{_T_LOWER}])(?=[{_T_UPPER}])|(?<=[{_T_UPPER}])(?=[{_T_UPPER}][{_T_LOWER}]))" ) _T_B0, _T_B1 = rf"(?:(?(?:https?://|(?\"'()\[\]{{}}]+)|(?P{_T_ADDRESS})") _T_DATE = r"\d{1,2}[/.-]\d{1,2}[/.-]\d{2,4}(?!\d)" # One pass, the first alternative that fits wins: links and addresses stay as the first pass # left them, then order numbers, labelled numbers, one-time codes, IBANs, long tokens of # letters and digits, phone numbers, tracking numbers and every other long digit run. # A number written in groups ("0034 0434 1610 9401 2345", "1234.5678.901"): groups of two to # six digits with one kind of separator (space, tab, dot or dash), ten digits or more in all. # Only digits joined by that one separator count ("2026-09-28 11:51" is a date and a time). # It may not end inside a date, a time or a price. With 14 digits or more it is no phone # number, so it is looked for before the phone rule (which took the first digits and left the # rest), with fewer digits after it. Without a label in front of it, a row of years ("2026 # 2027 2028") or of two-digit numbers ("10 20 30 40 50 60") is no number in groups. def _t_sscc_check(digits): """The GS1 check digit (mod 10) of the 19 digits in front of it.""" total = sum(int(d) * (3 if i % 2 == 0 else 1) for i, d in enumerate(reversed(digits))) return str((10 - total % 10) % 10) def _t_sscc(run): """An SSCC as carriers print it: "00", 18 digits, the last a check digit that fits.""" return bool(re.fullmatch(r"00[0-9]{18}", run)) and _t_sscc_check(run[:-1]) == run[-1] def _t_groups(minimum, labelled=False): runs = [] for gap in (" ", r"\t", r"\.", "-"): ahead = rf"(?=(?:{gap}?\d){{{minimum}}})" if not labelled: year, end = r"(?:19|20)\d\d", rf"(?!{gap}?\d)" ahead += rf"(?!(?:{year}{gap})+{year}{end})(?!(?:\d\d{gap})+\d\d{end})" runs.append(rf"{ahead}\d{{2,6}}(?:{gap}\d{{2,6}})+") return rf"(?[A-Za-z]{0,4}\d{8,30}[A-Za-z]{0,2})(?![A-Za-z0-9])" ) _T_HIDDEN_NOTE = "[Nummer nur im Link oder Bildtext: {}]" _T_HIDDEN_MARK = re.compile("\x00([^\x00]*)\x00") _T_GENERIC = re.compile( _T_LINKS.pattern + r"|(?P(?(?:(?i:[\w-]*(?:nummer|nr|number|code|pin|referenz|reference|passwort" + r"|kennwort))|\bID|\bTAN)\b[ \t.:#*]*\n?[ \t#*]*)" + rf"(?P{_T_S10_GROUPED}|{_T_GROUPED_LABELLED}|(?=[\w-]*\d)[A-Za-z0-9][A-Za-z0-9_-]{{2,}})" + r"|(?P(?i:passwort|kennwort|code|pin|tan)\b[^0-9\n]{0,40})(?P\d{4,8})(?!\d)" + rf"|(?P{_T_S10_GROUPED})" + r"|(?P(?(?{_T_GROUPED_LONG})" # up to three separators between two digits: "0211 / 123 45 67" + r"|(?P\+\d[\d /()-]{6,}\d" + rf"|(?{_T_GROUPED})" + r"|(?P(?(?\d{6,})" ) _T_PLACE = ( r"\w*(?:ablageort|abstellort|wunschort|ablageplatz|abstellplatz|abstellgenehmigung" r"|abstell-okay|abstellerlaubnis)\w*" ) _T_PLACED = r"(?:erkannt|hinterlegt|abgelegt|abgestellt|zugestellt|deponiert|gespeichert)" # "Abstellort" as a heading, the place on the next line that is no rule. _T_PLACE_NEXT = re.compile( rf"(?im)(^[^\n]{{0,40}}?{_T_PLACE}[*_ \t]*:?[*_ \t]*\n(?:[-=_* \t]*\n)*)[ \t]*([^\n]+)" ) _T_PLACE_SAME = re.compile(rf"(?i){_T_PLACE}[*_ \t]*:[*_ \t]*(\S[^\n]*)") _T_PLACE_PHRASE = re.compile( rf"(?i)({_T_PLACE})[ \t]+(?!{_T_PLACED}\b)[^\n]{{1,80}}?[ \t]+({_T_PLACED})\b" ) _T_RECIPIENT = ( r"(?:(?:Zustell|Liefer|Versand|Rechnungs)-?(?:adresse|anschrift)|Empf(?:ä|ae)nger(?:in)?" r"|Geliefert an|Lieferung an|Versand an|Die Sendung geht an" # eBay: "Ihre Bestellung wird verschickt an:", older mails "Versandziel:" r"|(?:Ihre Bestellung )?wird verschickt (?:an|nach)|Versandziel" r"|Ship(?:ping)?[ -]?(?:To|Address)|Deliver(?:y(?: Address)?| to))" ) _T_COUNTRY = r"(?:Deutschland|Germany|Österreich|Austria|Schweiz|DE|AT|CH)" # The label alone on its line, then up to five lines and the line with the postcode: the # postcode alone (a line with only the city below it goes too), "PLZ Ort", "Ort, PLZ" or # "ORT BUNDESLAND PLZ". _T_BARE_POSTCODE = ( r"[ \t]*(?:D-)?\d{5}[ \t]*,?[ \t]*" rf"(?:\n[ \t]*(?!{_T_COUNTRY}\b)[^\W\d_][^\s\d:*_]*(?:[ \t]+[^\s\d:*_]+){{0,2}}[ \t]*(?=\n|\Z))?" r"(?=\n|\Z)" ) _T_ADDRESS_BLOCK = re.compile( rf"(?im)^([*_ \t]*{_T_RECIPIENT}[*_ \t]*:?[*_ \t]*\n)(?:[^\n]*\n){{0,5}}?" rf"(?:{_T_BARE_POSTCODE}|[^\n:]{{0,60}}?\b\d{{5}}\b[^\n]*" r"|[ \t]*(?:(?:A|CH|D)[- ])?\d{4}[ \t]+[^\W\d_][^\n]*)" ) _T_ADDRESS_LINE = re.compile( rf"(?im)^([*_ \t]*{_T_RECIPIENT}[*_ \t]*:[ \t]*)(\S[^\n]*\b\d{{5}}\b[^\n]*)$" ) # "… an folgende Adresse zugestellt:" (DPD Austria), also at the end of a sentence: the # lines below it up to the line with the postcode (four or five digits), at most four lines. _T_ADDRESS_INTRO = re.compile( r"(?im)^([^\n]{0,80}?\b(?:(?:an[ \t]+)?(?:die[ \t]+)?folgende[nr]?[ \t]+(?:Adresse|Anschrift)" r"(?:[ \t]+(?:zugestellt|geliefert|gesendet|versendet|verschickt|zu))?" # "(bei einer Wunschzustellung ggf. abweichend)"; DPD in 2020: "Ich habe Ihr Paket # erfolgreich zugestellt:" r"(?:[ \t]*\([^()\n]{0,80}\))?|Ihr Paket erfolgreich zugestellt)[*_ \t]*:[*_ \t]*\n)" rf"(?:[^\n]*\n){{0,3}}?(?:{_T_BARE_POSTCODE}|[^\n:]{{0,60}}?\b\d{{5}}\b[^\n]*" r"|[ \t]*(?:(?:A|CH|D)[- ])?\d{4}[ \t]+[^\W\d_][^\n]*)" ) # AliExpress: "Versand nach", then place and street without a postcode; in most layouts the # name with the phone number "(+49) …" ends the block, in one it is a single line. The # block has as many lines as the address needs (street, addition, town, state, postcode), # so it is read up to the line with the phone number, never beyond the next label. _T_ALI_SHIP_TO = re.compile( r"(?im)^((?:Versand nach|Ship to|Lieferanschrift)[ \t]*\n)" r"(?:(?P(?:(?![^\n]*\(\+\d+\)|Zahlungsmethode\b|Payment method\b)[^\n]*\n){0,12})" r"(?P(?!Zahlungsmethode\b|Payment method\b)[^\n(]*)\(\+\d+\)[^\n]*" r"|(?P[^\n]+))" ) # Amazon until 2025: "Die Sendung geht an:", the first name and the place on the lines below. _T_AMAZON_SHIP_TO = re.compile( r"(?im)^([*_ \t]*Die Sendung geht an:[*_ \t]*\n[ \t]*)([^\n]{2,40}?)([ \t]*\n[ \t]*)" r"([^\n\d]{2,40}?)([ \t]*\n[ \t]*(?:Gesamtbetrag|Bestellnummer))" ) # The recipient's name behind "Empfänger:" or on the line below it, without an address (DPD). _T_RECIPIENT_NAME = re.compile( r"((?i:Empf(?:ä|ae)nger(?:in)?)[*_ \t]*:[*_ \t]*(?:\n[*_ \t]*)?)" r"((?:[A-ZÄÖÜ][^\W\d_]+(?:-[A-ZÄÖÜ][^\W\d_]+)*|[A-ZÄÖÜ]\.)" r"(?:[ \t]+(?:von|van|de|der|zu))?[ \t]+[A-ZÄÖÜ][^\W\d_]+(?:-[A-ZÄÖÜ][^\W\d_]+)*)" r"(?![^\W\d_])" ) # GLS delivery mail: "*an NAME*" and the address lines up to the next rule or empty line. _T_DELIVERED_TO = re.compile( r"(?m)^(\*an )([^*\n]{2,60})(\*[ \t]*\n)((?:[ \t]*[^\s-][^\n]*\n){0,4})" ) # A sender is kept only if it is a company or a known shop or carrier, never a person: # a legal form or a shop word anywhere, or a shop that is also a first name as the whole name. _T_COMPANY = re.compile( r"(?i)(? \t]*(?:Versender(?:in)?|Absender(?:in)?|Von|From)[*_ \t]*:[*_ \t]*)" r"([^\W\d_][^\n\d,;|(<@]*?)[ \t]*(?=$|[,;|(<])" ) # Neighbours and the person that took the parcel. _T_NEIGHBOUR = re.compile( r"(?P