Im Ticketsystem steht als Betreff =?UTF-8?B?QsO8cm8=?=, und im Absenderfeld sieht es genauso aus. Die Mail ist in Ordnung, das Postfach auch. Der Absender hat nur getan, was das Mailprotokoll verlangt: Eine Kopfzeile darf ausschließlich ASCII enthalten, alles andere wird vorher verpackt.
Beim Versand kodiert PHP den Betreff, damit er unbeschadet durch alte Mailserver kommt. Beim Empfang muss jemand das wieder auflösen, und genau dafür gibt es PHP iconv_mime_decode(). Wer die Gegenrichtung sucht, also das Kodieren vor dem Versand, findet sie im Tutorial zu quoted_printable_encode() und MIME-Encoding für E-Mails.
Warum im Betreff plötzlich =?UTF-8?B? steht
Die Verpackung heißt encoded-word und steht in RFC 2047. Sie besteht aus vier Teilen zwischen zwei festen Markierungen:
=?UTF-8?B?QsO8cm8=?=
=? Anfang des encoded-word
UTF-8 Zeichensatz der Nutzlast
B Kodierung, B oder Q
QsO8cm8= die Nutzlast
?= Ende des encoded-word
Das B steht für Base64, das Q für Quoted-Printable. Bei Q bleiben ASCII-Zeichen lesbar und nur die übrigen werden als =C3=9C geschrieben, bei B ist der ganze Abschnitt unleserlich. Beide Formen kommen vor, oft sogar in derselben Mail, weil jeder Absender sein eigenes Programm benutzt. Die Nutzlast QsO8cm8= ergibt dekodiert Büro.
Syntax: die Signatur und ihr Return Value
Der dritte Parameter von PHP iconv_mime_decode() ist der wichtigste, und er ist ausgerechnet der, den fast jedes Beispiel im Netz weglässt:
iconv_mime_decode(
string $string,
int $mode = 0,
?string $encoding = null
): string|false
/* string - the MIME header field to decode
mode - bitmask of the decode options, 0 by
default, the two constants are
listed further down
encoding - the character set the result is
converted to; when null, the setting
iconv.internal_encoding is used
Description und Return Values, wie im Handbuch:
"Decodes a MIME header field."
"Returns a decoded MIME field on success, or
false if an error occurs during the decoding." */
Der erste Parameter heißt im PHP-Manual MIME header field, gemeint ist eine Kopfzeile oder ihr Wert. Der dritte heißt encoding: Ohne encoding hängt das Ergebnis an einer Servereinstellung. Derselbe Code liefert dann auf dem Entwicklungsrechner sauberes UTF-8 und auf dem Produktivsystem Zeichensalat, ohne dass sich an der Mail etwas geändert hätte. Der Zeichensatz, im Manual character set, gehört als 'UTF-8' ausgeschrieben in den Aufruf.
Vom MIME-Header zum lesbaren Text mit PHP iconv_mime_decode()
PHP iconv_mime_decode() sucht im übergebenen Text nach encoded-words, löst sie auf und lässt alles andere unangetastet. Der Weg eines Feldes sieht so aus:
flowchart TD
A[Rohheader] --> B{encoded-word?}
B -- nein --> E[Text bleibt stehen]
B -- ja --> C[Zeichensatz und B/Q]
C --> D[Nutzlast dekodieren]
D --> F[Text in UTF-8]
In Code sind das zwei Zeilen. Wichtig ist nur, was man übergibt: den reinen Feldwert oder die ganze Zeile samt Feldnamen.
<?php
$roh = '=?UTF-8?B?QsO8cm8=?=';
$klar = iconv_mime_decode($roh, 0, 'UTF-8');
echo $klar;
/* Büro */
/* Mit Feldnamen bleibt dieser stehen: */
echo iconv_mime_decode(
'Subject: =?UTF-8?B?QsO8cm8=?=',
0,
'UTF-8'
);
/* Subject: Büro */
Der Feldname wird also nicht abgeschnitten. Wer aus einer Subject-Zeile nur den Wert will, trennt ihn vorher am ersten Doppelpunkt ab oder nimmt gleich die Variante für den ganzen Kopfbereich weiter unten.
Die drei Modi von PHP iconv_mime_decode()
Der zweite Parameter entscheidet, was bei einem fehlerhaften Abschnitt passiert. Genau hier entsteht der Eindruck, die Funktion sei kaputt, denn der Standardmodus wirft den ganzen Wert weg, sobald ein einziger Abschnitt nicht stimmt:
<?php
$kaputt = '=?UTF-8?B?QsO8cm8=?= =?UTF-8?X?ZZZ?=';
$a = @iconv_mime_decode($kaputt, 0, 'UTF-8');
$b = @iconv_mime_decode(
$kaputt,
ICONV_MIME_DECODE_STRICT,
'UTF-8'
);
$c = iconv_mime_decode(
$kaputt,
ICONV_MIME_DECODE_CONTINUE_ON_ERROR,
'UTF-8'
);
var_dump($a, $b, $c);
/* bool(false)
bool(false)
string(20) "Büro=?UTF-8?X?ZZZ?="
Ohne das @ melden die ersten beiden Aufrufe
auch eine Warnung, und zwar wortgleich:
"iconv_mime_decode(): Malformed string" */
Der Standardmodus und der strenge Modus verhalten sich hier also gleich, und der dritte Modus ist der einzige, der überhaupt etwas zurückgibt. Der kaputte Abschnitt bleibt dabei wörtlich stehen, er wird nicht entfernt. Das Leerzeichen zwischen den beiden Abschnitten fällt weg, weil es zwischen zwei encoded-words als Trenner gilt.
| Modus | Bei einem fehlerhaften Abschnitt | Passt zu |
0, der Standard | der ganze Aufruf liefert false und meldet eine Warnung | Schnittstellen, bei denen ein krummer Header ein echter Fehler ist |
ICONV_MIME_DECODE_STRICT | dasselbe, zusätzlich strengere Prüfung auf RFC-2047-Konformität | Zulieferer, die einen nach Vorschrift gebauten Header schulden |
ICONV_MIME_DECODE_CONTINUE_ON_ERROR | der fehlerhafte Abschnitt bleibt als Rohtext stehen, der Rest wird dekodiert | Anzeige und Stapelläufe, wo ein halb lesbarer Betreff besser ist als gar keiner |
Dass der strenge Modus nicht die Voreinstellung ist, hat einen Grund. Das PHP-Manual schreibt zur Option ICONV_MIME_DECODE_STRICT wörtlich: „This option is disabled by default because there are a lot of broken mail user agents that don't follow the specification and don't produce correct MIME headers.“ Auf Deutsch: Zu viele Mailprogramme halten sich nicht an die Vorschrift. Ein Postfachabgleich, der an der ersten krummen Kopfzeile stehenbleibt, hilft niemandem.
Für den Stapelbetrieb ist ICONV_MIME_DECODE_CONTINUE_ON_ERROR deshalb die richtige Wahl, aber nicht folgenlos. Aus 'Preis =? 5 Euro' wird in diesem Modus 'Preis ': Ein harmloses Fragezeichen hinter einem Gleichheitszeichen sieht für die Funktion wie der Anfang eines encoded-word aus, und ab dort reicht sie nichts mehr durch. Im Standardmodus liefert dieselbe Zeile false samt Warnung. Wer den dritten Modus wählt, prüft das Ergebnis deshalb mit str_contains($wert, '=?') und schreibt die Treffer ins Protokoll. Sonst landet halb dekodierter Text in der Datenbank, und in einem halben Jahr fragt jemand, warum bei vierzig Vorgängen der Kundenname fehlt.
Der Rückgabewert von PHP iconv_mime_decode() ist string|false, und das false ist im Standardmodus keine Ausnahme, sondern die Antwort auf jeden krummen Header. Die Prüfung gehört trotzdem mit === geschrieben: Ein leerer Betreff liefert einen leeren String, und der ist bei einem lockeren Vergleich von false nicht zu unterscheiden. Aus einer Mail ohne Betreff würde sonst ein Fehler, der keiner ist.
Den ganzen Kopfbereich auf einmal lesen
Liegt der komplette Kopfbereich als Text vor, etwa aus einer .eml-Datei oder aus einem IMAP-Abruf, lohnt der Umweg über einzelne Felder nicht. Die Schwesterfunktion iconv_mime_decode_headers() ist im Handbuch in einem Satz beschrieben: „Decodes multiple MIME header fields at once.“ Sie nimmt den ganzen Block und liefert ein Array mit den Feldnamen als Schlüssel.
<?php
$kopf = "Subject: =?UTF-8?B?QsO8cm8=?=\r\n"
. "From: anna@example.com\r\n"
. "Received: von relay-a\r\n"
. "Received: von relay-b\r\n";
$felder = iconv_mime_decode_headers(
$kopf,
ICONV_MIME_DECODE_CONTINUE_ON_ERROR,
'UTF-8'
);
/* Subject => Büro
From => anna@example.com
Received => ['von relay-a', 'von relay-b'] */
Der Fallstrick steckt in der letzten Zeile. Kommt ein Feld mehrfach vor, und Received tut das in jeder echten Mail, ist der Wert kein String mehr, sondern ein Array. Code, der stur einen String erwartet, fällt genau dort auf die Nase. Ein is_array() vor der Weiterverarbeitung, und die Schleife läuft auch durch ein Postfach mit zwanzig Zustellwegen.
Woher der Block stammt, ist PHP iconv_mime_decode_headers() gleich: eine gespeicherte .eml-Datei, ein Abruf mit imap_fetchheader() oder die Standardeingabe, wenn der Mailserver die Nachricht an ein Skript weiterreicht. Bei der zweiten Quelle lohnt ein Blick auf die PHP-Version: Die imap-Erweiterung ist seit PHP 8.4 nicht mehr Teil der Auslieferung und kommt seitdem über PECL. Entscheidend ist ansonsten, dass der Kopfbereich unverändert ankommt, mit seinen Zeilenumbrüchen und den führenden Leerzeichen der Folgezeilen. Wer ihn vorher zeilenweise durch trim() schickt, zerstört genau die Faltung, um die es weiter unten geht.
Betreff und Absendername aus einer eingegangenen Mail
Im From-Feld ist höchstens der Anzeigename kodiert. Die Adresse in spitzen Klammern steht immer im Klartext, denn sonst könnte kein Mailserver sie lesen. Nach dem Lauf durch PHP iconv_mime_decode() trennt ein Ausdruck beides voneinander:
<?php
$roh = '=?UTF-8?B?QW5uYSBNw7xsbGVy?='
. ' <anna@example.com>';
$klar = iconv_mime_decode($roh, 0, 'UTF-8');
$name = $klar;
$adresse = '';
if (preg_match('/^(.*)<(.+)>$/', $klar, $t)) {
$name = trim($t[1]);
$adresse = $t[2];
}
/* $name: Anna Müller
$adresse: anna@example.com */
Wer die Erweiterung mailparse auf dem Server hat, bekommt dasselbe mit mailparse_rfc822_parse_addresses() und muss sich um Sonderfälle wie Kommas im Anzeigenamen nicht kümmern. Installiert ist sie allerdings selten, und auf Shared Hosting fast nie. Der Ausdruck oben ist deshalb der Weg, der überall funktioniert.
PHP iconv_mime_decode oder mb_decode_mimeheader?
Beide lösen encoded-words auf, und beide liefern bei einer sauberen Mail dasselbe Ergebnis:
<?php
$roh = '=?ISO-8859-1?Q?Gr=FC=DFe?=';
echo iconv_mime_decode($roh, 0, 'UTF-8');
/* Grüße */
mb_internal_encoding('UTF-8');
echo mb_decode_mimeheader($roh);
/* Grüße */
Der Unterschied liegt in der Steuerung. Die Zielkodierung steht bei der iconv-Variante im Aufruf, bei mbstring in einer globalen Einstellung, die irgendwo weiter oben im Skript gesetzt wurde. Die drei Modi gibt es nur bei PHP iconv_mime_decode(), und für den kompletten Kopfbereich hat mbstring überhaupt keine Entsprechung. Umgekehrt kommt mb_decode_mimeheader() mit manchen eigenwillig kodierten Headern besser zurecht.
Beide Funktionen sind im Einsatz, und keine ist falsch. Wer den Zeichensatz des Ergebnisses im Aufruf festnageln will, nimmt die iconv-Variante. Wer ohnehin im ganzen Projekt mit mbstring arbeitet, bleibt konsequent dort. Zur Zielkodierung selbst lohnt ein Blick in die sichere Verarbeitung von Multibyte-Strings und UTF-8.
Falsch deklarierte Zeichensätze und gefaltete Header
Manche Programme behaupten ISO-8859-1 und schicken in Wahrheit UTF-8. PHP iconv_mime_decode() glaubt der Angabe im Header, denn etwas anderes hat sie nicht. Heraus kommt der bekannte Salat mit dem großen A und der Tilde:
<?php
$roh = '=?ISO-8859-1?B?TcO8bGxlcg==?=';
echo iconv_mime_decode($roh, 0, 'UTF-8');
/* Müller */
$fix = str_replace(
'=?ISO-8859-1?',
'=?UTF-8?',
$roh
);
echo iconv_mime_decode($fix, 0, 'UTF-8');
/* Müller */
Die Reihenfolge ist der Punkt: Die Angabe muss im Rohheader ersetzt werden, vor dem Dekodieren. Danach ist die Information weg, und aus dem Salat lässt sich das Original nur noch raten. Wenn das Ergebnis trotz richtiger Angabe Zeichen enthält, die im Ziel nicht darstellbar sind, helfen die Suffixe //TRANSLIT und //IGNORE an der Zielkodierung; wie eine solche Umschrift aussieht, zeigt das Tutorial zu transliterator_transliterate() für Umschrift und Slugs.
Der zweite Praxisfall sind lange Betreffzeilen. Sie werden auf mehrere Zeilen verteilt, und die Folgezeilen beginnen mit einem Leerzeichen oder Tabulator. Oft stehen dann mehrere encoded-words hintereinander:
<?php
$roh = "Subject: =?UTF-8?B?QsO8cm8=?=\r\n"
. " =?UTF-8?B?IHVuZCBMYWdlcg==?=";
echo iconv_mime_decode($roh, 0, 'UTF-8');
/* Subject: Büro und Lager */
PHP iconv_mime_decode() löst die Faltung also selbst auf und setzt die Abschnitte zusammen. Eigener Zerlegecode, der Zeilen zusammenklebt und nach =? sucht, ist damit überflüssig und schadet meistens mehr, als er nützt.
Fazit
PHP iconv_mime_decode() nimmt einen Headerwert, löst die encoded-words darin auf und gibt alles andere unverändert zurück. Drei Angaben entscheiden über das Ergebnis: der Feldwert oder die ganze Zeile, der Modus und die Zielkodierung. Der dritte Parameter gehört immer gesetzt, sonst entscheidet die Servereinstellung.
Bleibt bei einer Mail Rohtext im Ergebnis stehen, war ICONV_MIME_DECODE_CONTINUE_ON_ERROR im Spiel, und das ist Absicht. Kommt dagegen false samt der Warnung „Malformed string“ zurück, hat der Standardmodus wegen eines einzigen kaputten Abschnitts den ganzen Wert verworfen. Beides will behandelt werden, und die dritte Möglichkeit, halb dekodierten Text stillschweigend in die Datenbank zu schreiben, ist die schlechteste von allen.