From c4fd683ef3ae6dca0236ddb5fb5d2bb9b836dae1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=9A=D1=83=D1=80=D0=BD=D0=B0=D1=82=20=D0=90=D0=BD=D0=B4?=
=?UTF-8?q?=D1=80=D0=B5=D0=B9?=
Date: Tue, 2 Jun 2026 06:30:26 +0300
Subject: [PATCH] Preserve QOutlook HTML EML bodies
---
README.md | 3 +
src/data/sampleData.ts | 2 +
src/features/qoutlook/QOutlook.tsx | 98 +++++++++++++++++++++++++++++-
src/io/outlookMail.test.ts | 29 +++++++++
src/io/outlookMail.ts | 97 +++++++++++++++++++++++++++--
src/styles.css | 44 ++++++++++++++
src/types.ts | 1 +
7 files changed, 269 insertions(+), 5 deletions(-)
diff --git a/README.md b/README.md
index 15b6837..d19c375 100644
--- a/README.md
+++ b/README.md
@@ -48,6 +48,7 @@ npm run build
- QExcell открывает `.xlsx` и сохраняет `.xlsx`; сетка расширяется по фактически заполненным ячейкам, поддерживает несколько листов, скрытые и veryHidden листы через `sheet state`, закрепленные области через `sheetViews pane`, скрытие сетки через `sheetView showGridLines`, масштаб листа через `sheetView zoomScale`, автофильтр через `autoFilter ref`, область печати через `_xlnm.Print_Area`, цвет вкладки листа через `sheetPr tabColor`, ширину и скрытие колонок через `cols/col width` и `col hidden`, высоту и скрытие строк через `row ht/customHeight` и `row hidden`, управление скрытием выбранной строки и столбца в интерфейсе, активный лист через `workbookView activeTab`, формулы за пределами стартового диапазона A-H, shared formulas XLSX с относительными ссылками, базовые merged cells, внешние и внутренние гиперссылки ячеек, legacy-заметки ячеек через `comments.xml` и VML `legacyDrawing`, базовые диаграммы листа и базовое форматирование ячеек: полужирный, курсив, подчеркивание, зачеркивание, размер и семейство шрифта, прямые RGB/theme/indexed цвета текста с учетом `xl/theme/theme1.xml`, заливку, горизонтальное и вертикальное выравнивание, перенос текста, границы и цвет границ ячеек, числовые форматы XLSX для дат, процентов, денежных и дробных значений.
- QPowerPoint открывает `.pptx` и сохраняет `.pptx`; импорт учитывает порядок слайдов из `presentation.xml`, переносы абзацев и ручные переносы строк `a:br` внутри текстовых блоков, маркированные и нумерованные списки подзаголовка через `a:buChar`/`a:buAutoNum`, внешние ссылки текста заголовка/подзаголовка и изображений через `a:hlinkClick` и hyperlink relationships, скрытые слайды через атрибут `show`, переходы `p:transition` для `fade`/`push`/`wipe`, а также заметки докладчика через relationships слайда, сохраняет скрытые слайды, переходы слайдов, заметки докладчика, внешние ссылки текста заголовка/подзаголовка и изображений, базовые растровые изображения слайдов, пользовательский цвет фона слайда, списки подзаголовка, а также цвет, размер и семейство шрифта текста заголовка/подзаголовка.
- QOutlook открывает `.eml` и одиночные сообщения Outlook `.msg`, сохраняет письма в `.eml`; импорт EML поддерживает простые текстовые письма, `multipart/alternative`, `multipart/mixed`, `multipart/related`, `text/plain`, fallback из `text/html` в читаемый текст, base64, quoted-printable, MIME encoded-word заголовки UTF-8/windows-1251, MIME-вложения и inline-вложения с `Content-ID`, `To`, `Cc`, `Bcc`, `Reply-To`, `Message-ID`, `In-Reply-To`, `References`, `Importance`, `X-Priority` и `Priority`. Импорт MSG читает тему, отправителя, получателей `To/Cc/Bcc`, `Reply-To`, `Message-ID`, `In-Reply-To`, `References`, `Importance`, `X-Priority` и `Priority` из транспортных заголовков, текст/HTML body и вложения, если эти поля доступны в MSG-файле. Новые письма можно адресовать через `To/Cc/Bcc`, задавать `Reply-To` и сохранять в EML вместе с добавленными файлами. `.pst` и экспорт в `.msg` пока не реализованы.
+- QOutlook сохраняет HTML-тело импортированных EML/MSG писем в модели письма, показывает его в панели чтения через ограниченный HTML-preview без скриптов и экспортирует письма с HTML-телом в EML как `multipart/alternative`; при наличии вложений HTML и текстовая версии вкладываются внутрь `multipart/mixed`.
В desktop-режиме Electron используются системные диалоги открытия и сохранения файлов. В web-режиме остаются браузерный выбор файла и скачивание результата.
Экспорт PDF в desktop-режиме выполняется через Electron `webContents.printToPDF`; в web-режиме команда открывает браузерную печать, где пользователь может выбрать сохранение в PDF.
@@ -61,6 +62,8 @@ Desktop-меню поддерживает системные команды дл
Импорт и экспорт реализуют базовую совместимость с Office Open XML, EML и чтением MSG: текст, базовое inline-форматирование, явные `w:br`, `w:br w:type="page"`, `w:tab`, `w:color`, `w:shd`, `w:highlight`, `w:sz`, `w:rFonts` и `w:strike` для переносов строк/разрывов страниц/табуляции/цвета/подсветки/размера/семейства/зачеркивания текста DOCX, выравнивание и отступы абзацев DOCX через `w:jc`/`w:ind`, закладки DOCX через `w:bookmarkStart`/`w:bookmarkEnd`, внешние `w:hyperlink r:id` и внутренние `w:hyperlink w:anchor` гиперссылки DOCX, маркированные и нумерованные списки DOCX через `w:numPr` и `numbering.xml`, внешние и внутренние гиперссылки XLSX, legacy-заметки ячеек XLSX через `comments.xml`, relationship `comments`, VML `vmlDrawing` и `legacyDrawing`, базовые растровые изображения DOCX/PPTX, простые таблицы, несколько листов, скрытые/veryHidden листы через `sheet state`, закрепленные области через `sheetViews pane`, скрытие сетки через `sheetView showGridLines`, масштаб листа через `sheetView zoomScale`, автофильтр через `autoFilter ref`, область печати XLSX через `_xlnm.Print_Area`, цвет вкладки листа через `sheetPr tabColor`, ширину и скрытие колонок через `cols/col width`/`col hidden`, высоту и скрытие строк через `row ht/customHeight`/`row hidden` и активный лист XLSX через `workbookView activeTab`, ячейки, формулы, shared formulas XLSX с относительными ссылками, базовые merged cells, базовые стили, RGB/theme/indexed цвета XLSX с учетом `xl/theme/theme1.xml`, размеры и семейства шрифта, зачеркивание ячеек, горизонтальное и вертикальное выравнивание, перенос текста, границы и цвет границ ячеек, числовые форматы и простые bar/line/pie диаграммы ячеек XLSX, текстовые слайды PPTX с переносами абзацев и ручными переносами строк `a:br` при импорте, внешние ссылки текста заголовка/подзаголовка и изображений PPTX через `a:hlinkClick` и hyperlink relationships, скрытые слайды PPTX через атрибут `show`, переходы PPTX через `p:transition` для `fade`/`push`/`wipe`, пользовательский фон слайдов PPTX, цвет, размер и семейство шрифта текста заголовка/подзаголовка PPTX, импорт и экспорт заметок докладчика, адресацию `To/Cc/Bcc/Reply-To`, заголовки цепочки писем `Message-ID`/`In-Reply-To`/`References`, заголовки приоритета `Importance`/`X-Priority`/`Priority`, MIME encoded-word заголовки UTF-8/windows-1251, MIME-вложения EML, inline-вложения EML с `Content-ID` и базовые поля одиночных MSG-писем. Сложные стили, макросы, сложные диаграммы, сложное позиционирование и обтекание изображений, PST, экспорт MSG и полная fidelity документов Microsoft Office в текущем инкременте не заявлены.
+Для QOutlook EML round-trip дополнительно покрывает HTML body через `multipart/alternative` без вложений и с вложениями внутри `multipart/mixed`.
+
## Сборка пакета
```bash
diff --git a/src/data/sampleData.ts b/src/data/sampleData.ts
index 83622a4..955877f 100644
--- a/src/data/sampleData.ts
+++ b/src/data/sampleData.ts
@@ -146,6 +146,8 @@ export const initialState: QOfficeState = {
cc: "qa@qoffice.local",
subject: "Контрольный список запуска",
body: "Пожалуйста, проверьте этапы редакторов, цели упаковки и ответственных за качество до пятницы.",
+ bodyHtml:
+ "Контрольный список запуска
Пожалуйста, проверьте этапы редакторов, цели упаковки и ответственных за качество до пятницы.
- QWord: страницы и DOCX
- QExcell: XLSX round-trip
- QOutlook: EML/MSG импорт
",
time: "09:24",
read: false,
flagged: true,
diff --git a/src/features/qoutlook/QOutlook.tsx b/src/features/qoutlook/QOutlook.tsx
index 79241a0..a3193f4 100644
--- a/src/features/qoutlook/QOutlook.tsx
+++ b/src/features/qoutlook/QOutlook.tsx
@@ -38,6 +38,93 @@ function formatBytes(bytes: number): string {
return `${Number.isInteger(value) ? value : value.toFixed(1)} ${units[unitIndex]}`;
}
+const allowedMailHtmlTags = new Set([
+ "a",
+ "b",
+ "blockquote",
+ "br",
+ "code",
+ "div",
+ "em",
+ "h1",
+ "h2",
+ "h3",
+ "h4",
+ "h5",
+ "h6",
+ "hr",
+ "i",
+ "img",
+ "li",
+ "ol",
+ "p",
+ "pre",
+ "span",
+ "strong",
+ "sub",
+ "sup",
+ "table",
+ "tbody",
+ "td",
+ "tfoot",
+ "th",
+ "thead",
+ "tr",
+ "u",
+ "ul"
+]);
+
+const allowedMailHtmlAttributes = new Set(["align", "alt", "colspan", "height", "href", "rowspan", "src", "title", "width"]);
+const allowedMailHtmlProtocols = new Set(["http:", "https:", "mailto:", "tel:"]);
+const unsafeMailHtmlSelector = "script, style, iframe, object, embed, meta, link, base, form, input, button, textarea, select";
+
+function sanitizeMailHtml(html: string): string {
+ const parser = new DOMParser();
+ const document = parser.parseFromString(html, "text/html");
+
+ document.querySelectorAll(unsafeMailHtmlSelector).forEach((element) => element.remove());
+
+ document.body.querySelectorAll("*").forEach((element) => {
+ const tagName = element.tagName.toLowerCase();
+ if (!allowedMailHtmlTags.has(tagName)) {
+ element.replaceWith(...Array.from(element.childNodes));
+ return;
+ }
+
+ Array.from(element.attributes).forEach((attribute) => {
+ const name = attribute.name.toLowerCase();
+ if (!allowedMailHtmlAttributes.has(name) || name.startsWith("on") || !isSafeMailHtmlAttribute(name, attribute.value)) {
+ element.removeAttribute(attribute.name);
+ }
+ });
+
+ if (tagName === "a" && element.hasAttribute("href")) {
+ element.setAttribute("rel", "noopener noreferrer");
+ element.setAttribute("target", "_blank");
+ }
+ });
+
+ return document.body.innerHTML;
+}
+
+function isSafeMailHtmlAttribute(name: string, value: string): boolean {
+ if (name !== "href" && name !== "src") {
+ return true;
+ }
+
+ const trimmed = value.trim();
+ if (name === "src" && /^data:image\/(?:png|jpe?g|gif|bmp|webp);base64,/i.test(trimmed)) {
+ return true;
+ }
+
+ try {
+ const parsed = new URL(trimmed, window.location.origin);
+ return allowedMailHtmlProtocols.has(parsed.protocol);
+ } catch {
+ return false;
+ }
+}
+
export function QOutlook({ mail, onChange, onFolderChange }: QOutlookProps) {
const fileInputRef = useRef(null);
const draftAttachmentInputRef = useRef(null);
@@ -73,6 +160,7 @@ export function QOutlook({ mail, onChange, onFolderChange }: QOutlookProps) {
message.priority ?? "",
message.subject,
message.body,
+ message.bodyHtml ?? "",
...(message.attachments ?? []).map((attachment) => attachment.fileName)
].some((field) => field.toLowerCase().includes(normalizedQuery));
return inFolder && matchesQuery;
@@ -341,7 +429,15 @@ export function QOutlook({ mail, onChange, onFolderChange }: QOutlookProps) {
{activeMessage.priority ? `Priority: ${activeMessage.priority}` : ""}
) : null}
- {activeMessage.body}
+ {activeMessage.bodyHtml ? (
+
+ ) : (
+ {activeMessage.body}
+ )}
{activeAttachments.length > 0 ? (
Вложения
diff --git a/src/io/outlookMail.test.ts b/src/io/outlookMail.test.ts
index 9260318..c708f7e 100644
--- a/src/io/outlookMail.test.ts
+++ b/src/io/outlookMail.test.ts
@@ -7,6 +7,7 @@ import {
decodeTransferBody,
extractMessageAttachments,
extractMessageBody,
+ extractMessageHtmlBody,
htmlToPlainText,
importEmlFile,
isSupportedEmlFileName,
@@ -203,6 +204,7 @@ describe("outlookMail helpers", () => {
expect(imported.subject).toBe("Мультипарт");
expect(imported.body).toBe("Привет, письмо");
+ expect(imported.bodyHtml).toBe("HTML
");
expect(imported.from).toBe("sender@example.com");
expect(imported.to).toBe("reader@example.com");
expect(imported.cc).toBe("copy@example.com");
@@ -298,11 +300,13 @@ describe("outlookMail helpers", () => {
xPriority: "5",
priority: "non-urgent",
subject: "MSG отчет",
+ bodyHtml: "HTML текст
",
body: "HTML текст",
time: "Mon, 01 Jun 2026 10:00:00 GMT",
read: false,
flagged: false
});
+ expect(message.bodyHtml).toBe("HTML текст
");
expect(message.id).toMatch(/^msg-/);
expect(message.attachments).toEqual([
{
@@ -355,6 +359,28 @@ describe("outlookMail helpers", () => {
expect(eml.endsWith("Текст")).toBe(true);
});
+ it("exports HTML body as multipart/alternative EML", () => {
+ const message: MailMessage = {
+ id: "html-1",
+ folder: "sent",
+ from: "sender@example.com",
+ to: "reader@example.com",
+ subject: "HTML message",
+ body: "Plain text",
+ bodyHtml: "HTML text
",
+ time: "Mon, 01 Jan 2024 10:00:00 +0000",
+ read: true,
+ flagged: false
+ };
+
+ const eml = buildEmlSource(message);
+ const parsed = parseEmlSource(eml);
+
+ expect(eml).toContain("Content-Type: multipart/alternative");
+ expect(extractMessageBody(parsed.headers, parsed.body)).toBe("Plain text");
+ expect(extractMessageHtmlBody(parsed.headers, parsed.body)).toBe("HTML text
");
+ });
+
it("собирает multipart/mixed EML с вложениями и RFC 2231 filename", () => {
const message: MailMessage = {
id: "1",
@@ -372,6 +398,7 @@ describe("outlookMail helpers", () => {
priority: "urgent",
subject: "Тема",
body: "Текст",
+ bodyHtml: "Текст
",
time: "Mon, 01 Jan 2024 10:00:00 +0000",
read: true,
flagged: false,
@@ -392,6 +419,7 @@ describe("outlookMail helpers", () => {
const attachments = extractMessageAttachments(parsed.headers, parsed.body);
expect(eml).toContain("Content-Type: multipart/mixed");
+ expect(eml).toContain("Content-Type: multipart/alternative");
expect(eml).toContain("Cc: copy@example.com");
expect(eml).toContain("Bcc: hidden@example.com");
expect(eml).toContain("Reply-To: support@example.com");
@@ -403,6 +431,7 @@ describe("outlookMail helpers", () => {
expect(eml).toContain("Priority: urgent");
expect(eml).toContain("filename*=utf-8''%D0%BE%D1%82%D1%87%D0%B5%D1%82%2E%74%78%74");
expect(extractMessageBody(parsed.headers, parsed.body)).toBe("Текст");
+ expect(extractMessageHtmlBody(parsed.headers, parsed.body)).toBe("Текст
");
expect(attachments[0]).toMatchObject({
fileName: "отчет.txt",
contentType: "text/plain",
diff --git a/src/io/outlookMail.ts b/src/io/outlookMail.ts
index 6259d83..609b869 100644
--- a/src/io/outlookMail.ts
+++ b/src/io/outlookMail.ts
@@ -81,6 +81,7 @@ export async function importEmlFile(file: File): Promise {
const raw = await file.text();
const parsed = parseEmlSource(raw);
+ const bodyHtml = extractMessageHtmlBody(parsed.headers, parsed.body);
return {
id: `eml-${Date.now()}`,
@@ -94,6 +95,7 @@ export async function importEmlFile(file: File): Promise {
...mailPriorityHeaders(parsed.headers),
subject: decodeMimeWords(parsed.headers.subject || "Без темы"),
body: extractMessageBody(parsed.headers, parsed.body),
+ ...(bodyHtml ? { bodyHtml } : {}),
time: parsed.headers.date || new Date().toLocaleString("ru-RU"),
read: false,
flagged: false,
@@ -121,6 +123,7 @@ export function msgDataToMailMessage(reader: Pick !isAttachmentPart(part.headers))
+ .filter((part) => isMessageBodyPart(part.headers))
+ .map((part) => extractMessageHtmlBody(part.headers, part.body))
+ .find((html) => html.trim()) ?? ""
+ );
+ }
+
+ if (contentType.mediaType !== "text/html") {
+ return "";
+ }
+
+ return decodeTransferBody(body, headers["content-transfer-encoding"], contentType.parameters.charset).trim();
+}
+
export function extractMessageAttachments(headers: MailHeaders, body: string): MailAttachment[] {
return collectAttachmentParts(headers, body).map((part, index) => attachmentFromPart(part, index));
}
@@ -271,6 +300,19 @@ function msgBodyText(data: MsgFileData): string {
return "";
}
+function msgBodyHtml(data: MsgFileData): string {
+ const htmlBody = firstText(data.bodyHtml);
+ if (htmlBody) {
+ return htmlBody;
+ }
+
+ if (data.html && data.html.byteLength > 0) {
+ return decodeBytes(data.html, msgCharset(data)).trim();
+ }
+
+ return "";
+}
+
function msgCharset(data: MsgFileData) {
const headerFields = data.headers ? parseEmlHeaders(data.headers) : {};
return parseContentType(headerFields["content-type"]).parameters.charset || "utf-8";
@@ -417,8 +459,10 @@ export function parseContentType(value = "text/plain"): ParsedContentType {
export function buildEmlSource(message: MailMessage): string {
const attachments = message.attachments ?? [];
+ const hasHtmlBody = Boolean(message.bodyHtml?.trim());
if (attachments.length > 0) {
const boundary = createMultipartBoundary(message);
+ const alternativeBoundary = createAlternativeBoundary(message);
const lines = [
`From: ${sanitizeHeaderValue(message.from)}`,
`To: ${sanitizeHeaderValue(message.to)}`,
@@ -431,10 +475,7 @@ export function buildEmlSource(message: MailMessage): string {
`Content-Type: multipart/mixed; boundary="${boundary}"`,
"",
`--${boundary}`,
- "Content-Type: text/plain; charset=utf-8",
- "Content-Transfer-Encoding: 8bit",
- "",
- normalizeMailNewlines(message.body || ""),
+ ...(hasHtmlBody ? buildAlternativePartLines(alternativeBoundary, message) : buildPlainTextPartLines(message)),
...attachments.flatMap((attachment) => buildAttachmentPartLines(boundary, attachment)),
`--${boundary}--`,
""
@@ -443,6 +484,30 @@ export function buildEmlSource(message: MailMessage): string {
return lines.join("\r\n");
}
+ if (hasHtmlBody) {
+ const boundary = createAlternativeBoundary(message);
+ const lines = [
+ `From: ${sanitizeHeaderValue(message.from)}`,
+ `To: ${sanitizeHeaderValue(message.to)}`,
+ ...optionalAddressHeaders(message),
+ `Subject: ${encodeHeaderValue(message.subject || "Без темы")}`,
+ `Date: ${sanitizeHeaderValue(message.time || new Date().toUTCString())}`,
+ ...optionalThreadHeaders(message),
+ ...optionalPriorityHeaders(message),
+ "MIME-Version: 1.0",
+ `Content-Type: multipart/alternative; boundary="${boundary}"`,
+ "",
+ `--${boundary}`,
+ ...buildPlainTextPartLines(message),
+ `--${boundary}`,
+ ...buildHtmlPartLines(message),
+ `--${boundary}--`,
+ ""
+ ];
+
+ return lines.join("\r\n");
+ }
+
const lines = [
`From: ${sanitizeHeaderValue(message.from)}`,
`To: ${sanitizeHeaderValue(message.to)}`,
@@ -461,6 +526,26 @@ export function buildEmlSource(message: MailMessage): string {
return lines.join("\r\n");
}
+function buildPlainTextPartLines(message: MailMessage): string[] {
+ return ["Content-Type: text/plain; charset=utf-8", "Content-Transfer-Encoding: 8bit", "", normalizeMailNewlines(message.body || "")];
+}
+
+function buildHtmlPartLines(message: MailMessage): string[] {
+ return ["Content-Type: text/html; charset=utf-8", "Content-Transfer-Encoding: 8bit", "", normalizeMailNewlines(message.bodyHtml || "")];
+}
+
+function buildAlternativePartLines(boundary: string, message: MailMessage): string[] {
+ return [
+ `Content-Type: multipart/alternative; boundary="${boundary}"`,
+ "",
+ `--${boundary}`,
+ ...buildPlainTextPartLines(message),
+ `--${boundary}`,
+ ...buildHtmlPartLines(message),
+ `--${boundary}--`
+ ];
+}
+
function optionalAddressHeaders(message: MailMessage): string[] {
return [
...(message.cc?.trim() ? [`Cc: ${sanitizeHeaderValue(message.cc)}`] : []),
@@ -659,6 +744,10 @@ function createMultipartBoundary(message: MailMessage): string {
return `qoffice-${normalizedSeed || "message"}`;
}
+function createAlternativeBoundary(message: MailMessage): string {
+ return `${createMultipartBoundary(message)}-alternative`;
+}
+
function buildAttachmentPartLines(boundary: string, attachment: MailAttachment): string[] {
const safeContentType = sanitizeMimeType(attachment.contentType);
const disposition = attachment.disposition === "inline" ? "inline" : "attachment";
diff --git a/src/styles.css b/src/styles.css
index f5c539e..2c7abda 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -1693,6 +1693,50 @@ button {
font-size: 15px;
}
+.message-html-body {
+ max-width: 840px;
+ overflow-x: auto;
+ color: #111827;
+ font-size: 15px;
+ line-height: 1.58;
+}
+
+.message-html-body :where(p, ul, ol, blockquote, pre, table) {
+ margin: 0 0 12px;
+}
+
+.message-html-body :where(h1, h2, h3, h4, h5, h6) {
+ margin: 18px 0 10px;
+ color: #111827;
+ line-height: 1.25;
+}
+
+.message-html-body :where(ul, ol) {
+ padding-left: 22px;
+}
+
+.message-html-body table {
+ width: max-content;
+ max-width: 100%;
+ border-collapse: collapse;
+}
+
+.message-html-body :where(th, td) {
+ padding: 6px 8px;
+ border: 1px solid #d7dce5;
+ text-align: left;
+ vertical-align: top;
+}
+
+.message-html-body img {
+ max-width: 100%;
+ height: auto;
+}
+
+.message-html-body a {
+ color: #0f5fae;
+}
+
.attachments-panel {
max-width: 760px;
margin-top: 24px;
diff --git a/src/types.ts b/src/types.ts
index 7d2e638..4b52ff1 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -145,6 +145,7 @@ export interface MailMessage {
priority?: string;
subject: string;
body: string;
+ bodyHtml?: string;
time: string;
read: boolean;
flagged: boolean;