diff --git a/README.md b/README.md index 8951ff9..c10bb9a 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ npm run build - QWord открывает `.docx` и сохраняет `.docx`; при импорте базовых DOCX-абзацев читает выравнивание, явные цвета текста и подсветку из OOXML, а при экспорте DOCX сохраняются заголовки, абзацы, выравнивание абзацев, списки, простые таблицы, внешние гиперссылки, базовые растровые изображения и базовое inline-форматирование текста: полужирный, курсив, подчеркивание, цвет текста и подсветка. - QExcell открывает `.xlsx` и сохраняет `.xlsx`; сетка расширяется по фактически заполненным ячейкам, поддерживает несколько листов, формулы за пределами стартового диапазона A-H, базовые merged cells, внешние гиперссылки ячеек, базовые диаграммы листа и базовое форматирование ячеек: полужирный, курсив, подчеркивание, цвет текста, заливку, горизонтальное выравнивание и числовые форматы XLSX для дат, процентов, денежных и дробных значений. - QPowerPoint открывает `.pptx` и сохраняет `.pptx`; импорт учитывает порядок слайдов из `presentation.xml`, читает заметки докладчика через relationships слайда, сохраняет базовые растровые изображения слайдов и пользовательский цвет фона слайда. -- QOutlook открывает `.eml` и одиночные сообщения Outlook `.msg`, сохраняет письма в `.eml`; импорт EML поддерживает простые текстовые письма, `multipart/alternative`, `multipart/mixed`, `text/plain`, fallback из `text/html` в читаемый текст, base64, quoted-printable и MIME-вложения. Импорт MSG читает тему, отправителя, получателей, текст/HTML body и вложения, если эти поля доступны в MSG-файле. Новые письма можно сохранять в EML вместе с добавленными файлами. `.pst` и экспорт в `.msg` пока не реализованы. +- QOutlook открывает `.eml` и одиночные сообщения Outlook `.msg`, сохраняет письма в `.eml`; импорт EML поддерживает простые текстовые письма, `multipart/alternative`, `multipart/mixed`, `text/plain`, fallback из `text/html` в читаемый текст, base64, quoted-printable, MIME-вложения, `To`, `Cc` и `Bcc`. Импорт MSG читает тему, отправителя, получателей `To/Cc/Bcc`, текст/HTML body и вложения, если эти поля доступны в MSG-файле. Новые письма можно адресовать через `To/Cc/Bcc` и сохранять в EML вместе с добавленными файлами. `.pst` и экспорт в `.msg` пока не реализованы. В desktop-режиме Electron используются системные диалоги открытия и сохранения файлов. В web-режиме остаются браузерный выбор файла и скачивание результата. Экспорт PDF в desktop-режиме выполняется через Electron `webContents.printToPDF`; в web-режиме команда открывает браузерную печать, где пользователь может выбрать сохранение в PDF. @@ -46,7 +46,7 @@ Desktop-меню поддерживает системные команды дл - `Ctrl/Cmd+Shift+E`: экспортировать текущую рабочую область в PDF. - `Ctrl/Cmd+P`: печать текущей рабочей области. -Импорт и экспорт реализуют базовую совместимость с Office Open XML, EML и чтением MSG: текст, базовое inline-форматирование, явные `w:color`, `w:shd` и `w:highlight` для цвета/подсветки текста DOCX, выравнивание абзацев DOCX, внешние гиперссылки DOCX/XLSX, базовые растровые изображения DOCX/PPTX, простые таблицы, несколько листов XLSX, ячейки, формулы, базовые merged cells, базовые стили, цвета текста ячеек, горизонтальное выравнивание ячеек, числовые форматы и простые bar/line/pie диаграммы ячеек XLSX, текстовые слайды PPTX, пользовательский фон слайдов PPTX, заметки докладчика, MIME-вложения EML и базовые поля одиночных MSG-писем. Сложные стили, макросы, сложные диаграммы, сложное позиционирование и обтекание изображений, PST, экспорт MSG и полная fidelity документов Microsoft Office в текущем инкременте не заявлены. +Импорт и экспорт реализуют базовую совместимость с Office Open XML, EML и чтением MSG: текст, базовое inline-форматирование, явные `w:color`, `w:shd` и `w:highlight` для цвета/подсветки текста DOCX, выравнивание абзацев DOCX, внешние гиперссылки DOCX/XLSX, базовые растровые изображения DOCX/PPTX, простые таблицы, несколько листов XLSX, ячейки, формулы, базовые merged cells, базовые стили, цвета текста ячеек, горизонтальное выравнивание ячеек, числовые форматы и простые bar/line/pie диаграммы ячеек XLSX, текстовые слайды PPTX, пользовательский фон слайдов PPTX, заметки докладчика, адресацию `To/Cc/Bcc`, MIME-вложения EML и базовые поля одиночных MSG-писем. Сложные стили, макросы, сложные диаграммы, сложное позиционирование и обтекание изображений, PST, экспорт MSG и полная fidelity документов Microsoft Office в текущем инкременте не заявлены. ## Сборка пакета diff --git a/src/data/sampleData.ts b/src/data/sampleData.ts index 9a6887d..3dacaf1 100644 --- a/src/data/sampleData.ts +++ b/src/data/sampleData.ts @@ -139,6 +139,7 @@ export const initialState: QOfficeState = { folder: "inbox", from: "Мария Чен", to: "product@qoffice.local", + cc: "qa@qoffice.local", subject: "Контрольный список запуска", body: "Пожалуйста, проверьте этапы редакторов, цели упаковки и ответственных за качество до пятницы.", time: "09:24", diff --git a/src/features/qoutlook/QOutlook.tsx b/src/features/qoutlook/QOutlook.tsx index 56a4572..6b5f3cc 100644 --- a/src/features/qoutlook/QOutlook.tsx +++ b/src/features/qoutlook/QOutlook.tsx @@ -42,8 +42,10 @@ export function QOutlook({ mail, onChange, onFolderChange }: QOutlookProps) { const fileInputRef = useRef(null); const draftAttachmentInputRef = useRef(null); const [query, setQuery] = useState(""); - const [draft, setDraft] = useState<{ to: string; subject: string; body: string; attachments: MailAttachment[] }>({ + const [draft, setDraft] = useState<{ to: string; cc: string; bcc: string; subject: string; body: string; attachments: MailAttachment[] }>({ to: "", + cc: "", + bcc: "", subject: "", body: "", attachments: [] @@ -56,9 +58,15 @@ export function QOutlook({ mail, onChange, onFolderChange }: QOutlookProps) { const inFolder = message.folder === mail.activeFolder; const matchesQuery = !normalizedQuery || - [message.from, message.to, message.subject, message.body, ...(message.attachments ?? []).map((attachment) => attachment.fileName)].some((field) => - field.toLowerCase().includes(normalizedQuery) - ); + [ + message.from, + message.to, + message.cc ?? "", + message.bcc ?? "", + message.subject, + message.body, + ...(message.attachments ?? []).map((attachment) => attachment.fileName) + ].some((field) => field.toLowerCase().includes(normalizedQuery)); return inFolder && matchesQuery; }); }, [mail.activeFolder, mail.messages, query]); @@ -81,6 +89,8 @@ export function QOutlook({ mail, onChange, onFolderChange }: QOutlookProps) { folder: "sent", from: "product@qoffice.local", to: draft.to.trim(), + ...(draft.cc.trim() ? { cc: draft.cc.trim() } : {}), + ...(draft.bcc.trim() ? { bcc: draft.bcc.trim() } : {}), subject: draft.subject.trim(), body: draft.body.trim(), time: new Date().toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" }), @@ -95,7 +105,7 @@ export function QOutlook({ mail, onChange, onFolderChange }: QOutlookProps) { activeMessageId: id, messages: [message, ...current.messages] })); - setDraft({ to: "", subject: "", body: "", attachments: [] }); + setDraft({ to: "", cc: "", bcc: "", subject: "", body: "", attachments: [] }); } async function openMailFile(file: File | undefined) { @@ -296,6 +306,13 @@ export function QOutlook({ mail, onChange, onFolderChange }: QOutlookProps) {

От: {activeMessage.from} · Кому: {activeMessage.to} · {activeMessage.time}

+ {activeMessage.cc || activeMessage.bcc ? ( +

+ {activeMessage.cc ? `Копия: ${activeMessage.cc}` : ""} + {activeMessage.cc && activeMessage.bcc ? " · " : ""} + {activeMessage.bcc ? `Скрытая копия: ${activeMessage.bcc}` : ""} +

+ ) : null}

{activeMessage.body}

{activeAttachments.length > 0 ? (
@@ -335,6 +352,13 @@ export function QOutlook({ mail, onChange, onFolderChange }: QOutlookProps) { >

Новое письмо

setDraft({ ...draft, to: event.target.value })} placeholder="Кому" aria-label="Кому" /> + setDraft({ ...draft, cc: event.target.value })} placeholder="Копия" aria-label="Копия" /> + setDraft({ ...draft, bcc: event.target.value })} + placeholder="Скрытая копия" + aria-label="Скрытая копия" + /> setDraft({ ...draft, subject: event.target.value })} diff --git a/src/io/outlookMail.test.ts b/src/io/outlookMail.test.ts index 10e5c25..354b32a 100644 --- a/src/io/outlookMail.test.ts +++ b/src/io/outlookMail.test.ts @@ -138,6 +138,8 @@ describe("outlookMail helpers", () => { const eml = [ "From: sender@example.com", "To: reader@example.com", + "Cc: copy@example.com", + "Bcc: hidden@example.com", "Subject: =?UTF-8?B?0JzRg9C70YzRgtC40L/QsNGA0YI=?=", "Date: Mon, 01 Jun 2026 10:00:00 +0300", 'Content-Type: multipart/alternative; boundary="alt"', @@ -160,6 +162,8 @@ describe("outlookMail helpers", () => { expect(imported.body).toBe("Привет, письмо"); expect(imported.from).toBe("sender@example.com"); expect(imported.to).toBe("reader@example.com"); + expect(imported.cc).toBe("copy@example.com"); + expect(imported.bcc).toBe("hidden@example.com"); }); it("импортирует multipart/mixed EML с вложением", async () => { @@ -213,7 +217,8 @@ describe("outlookMail helpers", () => { messageDeliveryTime: "Mon, 01 Jun 2026 10:00:00 GMT", recipients: [ { smtpAddress: "reader@example.com", recipType: "to" }, - { smtpAddress: "copy@example.com", recipType: "cc" } + { smtpAddress: "copy@example.com", recipType: "cc" }, + { smtpAddress: "hidden@example.com", recipType: "bcc" } ], attachments: [{ fileName: "report.txt", attachMimeTag: "text/plain", contentLength: 5 }] } @@ -223,6 +228,8 @@ describe("outlookMail helpers", () => { folder: "inbox", from: "sender@example.com", to: "reader@example.com", + cc: "copy@example.com", + bcc: "hidden@example.com", subject: "MSG отчет", body: "HTML текст", time: "Mon, 01 Jun 2026 10:00:00 GMT", @@ -249,6 +256,8 @@ describe("outlookMail helpers", () => { folder: "sent", from: "sender@example.com", to: "reader@example.com", + cc: "copy@example.com", + bcc: "hidden@example.com", subject: "Тема", body: "Текст", time: "Mon, 01 Jan 2024 10:00:00 +0000", @@ -259,6 +268,8 @@ describe("outlookMail helpers", () => { const eml = buildEmlSource(message); expect(eml).toContain("From: sender@example.com"); + expect(eml).toContain("Cc: copy@example.com"); + expect(eml).toContain("Bcc: hidden@example.com"); expect(eml).toContain("Subject: =?UTF-8?B?"); expect(eml.endsWith("Текст")).toBe(true); }); @@ -269,6 +280,8 @@ describe("outlookMail helpers", () => { folder: "sent", from: "sender@example.com", to: "reader@example.com", + cc: "copy@example.com", + bcc: "hidden@example.com", subject: "Тема", body: "Текст", time: "Mon, 01 Jan 2024 10:00:00 +0000", @@ -291,6 +304,8 @@ describe("outlookMail helpers", () => { const attachments = extractMessageAttachments(parsed.headers, parsed.body); expect(eml).toContain("Content-Type: multipart/mixed"); + expect(eml).toContain("Cc: copy@example.com"); + expect(eml).toContain("Bcc: hidden@example.com"); 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(attachments[0]).toMatchObject({ diff --git a/src/io/outlookMail.ts b/src/io/outlookMail.ts index 62f188a..70f8e8c 100644 --- a/src/io/outlookMail.ts +++ b/src/io/outlookMail.ts @@ -87,6 +87,8 @@ export async function importEmlFile(file: File): Promise { folder: "inbox", from: decodeMimeWords(parsed.headers.from || ""), to: decodeMimeWords(parsed.headers.to || ""), + ...(parsed.headers.cc ? { cc: decodeMimeWords(parsed.headers.cc) } : {}), + ...(parsed.headers.bcc ? { bcc: decodeMimeWords(parsed.headers.bcc) } : {}), subject: decodeMimeWords(parsed.headers.subject || "Без темы"), body: extractMessageBody(parsed.headers, parsed.body), time: parsed.headers.date || new Date().toLocaleString("ru-RU"), @@ -113,11 +115,15 @@ export async function importMsgFile(file: File): Promise { export function msgDataToMailMessage(reader: Pick, data: MsgFileData): MailMessage { const headerFields = data.headers ? parseEmlHeaders(data.headers) : {}; + const cc = msgRecipientList(data.recipients, "cc") || decodeMimeWords(headerFields.cc || ""); + const bcc = msgRecipientList(data.recipients, "bcc") || decodeMimeWords(headerFields.bcc || ""); return { id: `msg-${Date.now()}`, folder: "inbox", from: firstText(data.senderSmtpAddress, data.senderEmail, data.senderName, decodeMimeWords(headerFields.from || "")), to: msgRecipientList(data.recipients, "to") || decodeMimeWords(headerFields.to || ""), + ...(cc ? { cc } : {}), + ...(bcc ? { bcc } : {}), subject: firstText(data.subject, decodeMimeWords(headerFields.subject || ""), "Без темы"), body: msgBodyText(data), time: firstText(data.messageDeliveryTime, data.clientSubmitTime, data.creationTime, data.lastModificationTime, headerFields.date, new Date().toLocaleString("ru-RU")), @@ -264,7 +270,7 @@ function msgCharset(data: MsgFileData) { function msgRecipientList(recipients: MsgRecipient[] = [], type: MsgRecipient["recipType"] = "to") { return recipients - .filter((recipient) => !recipient.recipType || recipient.recipType === type) + .filter((recipient) => (type === "to" ? !recipient.recipType || recipient.recipType === "to" : recipient.recipType === type)) .map((recipient) => firstText(recipient.smtpAddress, recipient.email, recipient.name)) .filter(Boolean) .join(", "); @@ -408,6 +414,7 @@ export function buildEmlSource(message: MailMessage): string { const lines = [ `From: ${sanitizeHeaderValue(message.from)}`, `To: ${sanitizeHeaderValue(message.to)}`, + ...optionalAddressHeaders(message), `Subject: ${encodeHeaderValue(message.subject || "Без темы")}`, `Date: ${sanitizeHeaderValue(message.time || new Date().toUTCString())}`, "MIME-Version: 1.0", @@ -429,6 +436,7 @@ export function buildEmlSource(message: MailMessage): string { const lines = [ `From: ${sanitizeHeaderValue(message.from)}`, `To: ${sanitizeHeaderValue(message.to)}`, + ...optionalAddressHeaders(message), `Subject: ${encodeHeaderValue(message.subject || "Без темы")}`, `Date: ${sanitizeHeaderValue(message.time || new Date().toUTCString())}`, "MIME-Version: 1.0", @@ -441,6 +449,13 @@ export function buildEmlSource(message: MailMessage): string { return lines.join("\r\n"); } +function optionalAddressHeaders(message: MailMessage): string[] { + return [ + ...(message.cc?.trim() ? [`Cc: ${sanitizeHeaderValue(message.cc)}`] : []), + ...(message.bcc?.trim() ? [`Bcc: ${sanitizeHeaderValue(message.bcc)}`] : []) + ]; +} + export function decodeMimeWords(value: string): string { return value.replace(/=\?([^?]+)\?([bqBQ])\?([^?]+)\?=/g, (_match, charset: string, encoding: string, encoded: string) => { const normalizedCharset = charset.toLowerCase(); diff --git a/src/types.ts b/src/types.ts index 69d8a46..c36ddcc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -93,6 +93,8 @@ export interface MailMessage { folder: "inbox" | "sent" | "drafts" | "archive"; from: string; to: string; + cc?: string; + bcc?: string; subject: string; body: string; time: string;