Зачем почте Base64
Интернет-почта создавалась для 7-битного текста ASCII в строках ограниченной длины. RFC 5322 до сих пор ограничивает строку 998 символами, а SMTP гарантирует безопасную доставку только 7-битных данных, если принимающий сервер не объявил расширение 8BITMIME. Даже при 8-битной передаче в письме не должно быть байтов NUL и одиночных символов возврата каретки и перевода строки, а в любом двоичном файле они рано или поздно встречаются. Для отправки сырых байтов нужно расширение BINARYMIME, и отправитель не может рассчитывать на него на всём пути от сервера к серверу.
MIME (RFC 2045) решает это заголовком Content-Transfer-Encoding. Он сообщает получателю, как тело части превратили в безопасный текст. Значений пять:
| Значение | Смысл |
|---|---|
7bit | Обычные строки ASCII, без кодирования |
8bit | 8-битный текст в коротких строках, нужен 8BITMIME |
binary | Сырые байты, нужен BINARYMIME |
quoted-printable | В основном читаемый текст, байты не из ASCII экранированы как =XX |
base64 | Base64 в строках не длиннее 76 символов |
Для вложений почтовые программы почти всегда выбирают base64. Сам алгоритм разобран в статье как работает Base64.
Base64 или quoted-printable
Quoted-printable оставляет символы ASCII как есть, а каждый остальной байт записывает как = и две hex-цифры. é в UTF-8 (байты C3 A9) превращается в =C3=A9. Строки длиннее 76 символов разбиваются мягким переносом, то есть знаком = в конце строки.
Поэтому он хорош для английского текста с редкими диакритиками и расточителен для всего остального: каждый байт не из ASCII стоит 3 символа. Base64 стоит ровно 4/3 независимо от содержимого. Мы закодировали по 20 повторов предложения на трёх языках функциями quopri.encodestring и base64.encodebytes из Python (обе с окончаниями строк LF):
| Пример (UTF-8) | Байты | Quoted-printable | Base64 |
|---|---|---|---|
| Английская панграмма | 900 | 926 | 1216 |
| Французское предложение с диакритиками | 1060 | 1830 | 1435 |
| Русская панграмма | 1600 | 4602 | 2165 |
Обычно этот выбор делает за вас почтовый клиент: quoted-printable или 7bit для текста почти целиком из ASCII, Base64 для вложений и для текста не латиницей. Если нужно прочитать или сформировать quoted-printable вручную, инструмент quoted-printable конвертирует в обе стороны.
Вложения в сыром письме
Тело письма с вложением имеет тип multipart/mixed. У каждой части свои заголовки, пустая строка, а затем содержимое. Вот полное минимальное письмо, сгенерированное пакетом email из Python (граница сокращена для читаемости):
From: Ana <ana@example.com>
To: bo@example.org
Subject: =?utf-8?b?Q2Fmw6kgbWVudSDigJMgR3LDtsOfZQ==?=
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="b1"
--b1
Content-Type: text/plain; charset="utf-8"
Content-Transfer-Encoding: quoted-printable
Hi Bo,
The caf=C3=A9 menu is attached.
--b1
Content-Type: application/pdf
Content-Transfer-Encoding: base64
Content-Disposition: attachment; filename="menu.pdf"
JVBERi0xLjcKJeLjz9MKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyA+PgplbmRvYmoKdHJhaWxl
cgo8PCAvUm9vdCAxIDAgUiA+PgolJUVPRgo=
--b1--
На что смотреть:
Content-Typeуказывает тип файла,Content-Dispositionзадаёт имя файла.Content-Transfer-Encoding: base64подтверждает, что тело в Base64.- Base64 начинается после первой пустой строки части и заканчивается перед следующей строкой
--boundary. - Все строки, кроме последней, ровно по 76 символов. Каждая полная строка содержит 57 байт файла.
JVBERi0в начале кодирует%PDF-, значит, перед нами PDF. Другие сигнатуры перечислены в статье частые префиксы Base64.
Картинки, встроенные в HTML-письма, используют тот же механизм в части multipart/related. Каждая картинка получает заголовок Content-ID, а HTML ссылается на неё как <img src="cid:...">. При передаче эти строки заканчиваются на CRLF, так что каждые 76 символов Base64 обходятся в 78 байт; полный расчёт есть в статье рост размера в Base64. Чтобы самому получить вывод с переносами, используйте кодировщик MIME Base64.
Закодированные заголовки: =?UTF-8?B?…?=
Заголовки вроде Subject, отображаемые имена в From и некоторые имена файлов должны быть в ASCII. Любой другой текст RFC 2047 кодирует как encoded-words:
=?charset?encoding?encoded-text?=
На месте encoding стоит B для Base64 или Q для варианта quoted-printable, в котором _ обозначает пробел. Регистр обеих букв не важен, поэтому выше Python написал =?utf-8?b?. Несколько реальных примеров, все проверены функцией email.header.decode_header из Python:
| В заголовке | После декодирования |
|---|---|
=?UTF-8?B?0J/RgNC40LLQtdGC?= | Привет |
=?utf-8?q?Caf=C3=A9_menu?= | Café menu |
=?ISO-8859-1?Q?Caf=E9_menu?= | Café menu |
=?utf-8?b?Q2Fmw6kgbWVudSDigJMgR3LDtsOfZQ==?= | Café menu – Größe |
Encoded-word может быть не длиннее 75 символов, поэтому длинные темы разбиваются на несколько слов, по одному на каждую свёрнутую строку заголовка. По RFC 2047 пробелы между соседними encoded-words игнорируются, а каждое слово должно содержать целые символы, так что многобайтовый символ UTF-8 никогда не разрывается между двумя словами. Русская тема из 44 символов вышла из Python такой:
Subject: =?utf-8?b?0KHRh9GR0YIg0LfQsCDQvtC60YLRj9Cx0YDRjCDQuCDQsNC60YIg0YE=?=
=?utf-8?b?0LLQtdGA0LrQuCDQtNC70Y8g0LHRg9GF0LPQsNC70YLQtdGA0LjQuA==?=
Каждое слово образует законченную строку Base64 со своим дополнением. По отдельности первое декодируется в Счёт за октябрь и акт с, второе в верки для бухгалтерии; декодер склеивает их в исходную тему.
Для имён файлов вложений есть второй стандарт, RFC 2231, который выглядит так: filename*=UTF-8''%D0%BE%D1%82%D1%87%D1%91%D1%82.pdf. Многие почтовые программы к тому же вставляют encoded-words по RFC 2047 внутрь filename="...", так что парсер должен понимать оба варианта.
Как извлечь вложение из файла .eml
В браузере. Откройте .eml в декодере EML, чтобы прочитать тела письма и сохранить вложения. Он работает локально на странице, а это важно, когда в письме счета или персональные данные.
На Python. Стандартная библиотека разбирает MIME и декодирует и тела в Base64, и заголовки по RFC 2047:
import sys
from email import policy
from email.parser import BytesParser
with open(sys.argv[1], "rb") as f:
msg = BytesParser(policy=policy.default).parse(f)
print("Subject:", msg["Subject"]) # encoded-words decoded
for part in msg.iter_attachments():
name = part.get_filename()
data = part.get_content() # Base64 already decoded to bytes
with open(name, "wb") as out:
out.write(data)
print("saved", name, len(data), "bytes")
На примере письма выше скрипт вывел Subject: Café menu – Größe и saved menu.pdf 83 bytes. В реальном коде очищайте name перед записью: его задаёт отправитель.
Вручную. Скопируйте строки Base64 нужной части в файл и декодируйте. Сырые файлы .eml используют окончания строк CRLF, и GNU base64 -d останавливается с invalid input на первом же возврате каретки. Сначала удалите их или передайте -i, чтобы пропускать символы вне алфавита:
tr -d '\r' < block.txt | base64 -d > menu.pdf
openssl base64 -d -in block.txt -out menu.pdf в наших тестах принял вариант с CRLF без изменений. Если у вас есть только текст в Base64, вставьте его в Base64 в файл (инструмент определит тип по первым байтам), а PDF можно сразу открыть в Base64 в PDF.
Заголовки, тела и вложения сырого письма, декодированные локально в вашем браузере.
Вопросы и ответы
Как открыть вложение в Base64 из исходника письма?
Скопируйте строки Base64 между пустой строкой части и следующей границей (boundary) и декодируйте их как файл. Декодер EML сам извлечёт вложения из файла .eml.
Что значит =?UTF-8?B? в теме письма?
Так начинается encoded-word по RFC 2047: кодировка UTF-8, а B означает, что текст между следующими ? и ?= записан в Base64. Q вместо B означает вариант quoted-printable.
Почему строки Base64 в письмах длиной 76 символов?
RFC 2045 ограничивает строки Base64 в MIME 76 символами. 76 символов вмещают ровно 57 байт, а сам лимит держит строки намного короче максимума в 998 символов, который допускает почта.
Что лучше для текста письма: quoted-printable или Base64?
Quoted-printable подходит для текста почти целиком из ASCII: он остаётся читаемым и почти не растёт. Base64 лучше для двоичных данных и текста не латиницей, где quoted-printable может увеличить размер почти втрое.