diff --git a/msg_viewer/__init__.py b/msg_viewer/__init__.py new file mode 100644 index 0000000..f7209b1 --- /dev/null +++ b/msg_viewer/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import controllers diff --git a/msg_viewer/__manifest__.py b/msg_viewer/__manifest__.py new file mode 100644 index 0000000..af3f51e --- /dev/null +++ b/msg_viewer/__manifest__.py @@ -0,0 +1,43 @@ +{ + 'name': 'MSG Viewer', + 'version': '1.0', + 'category': 'Hidden', + 'summary': 'MSG file viewer for Odoo', + 'description': """ + This module adds support for viewing MSG files directly in Odoo. + It allows users to preview Outlook MSG files without downloading them. + + It is based on the MSGViewer module by Markiian Karpa + https://github.com/molotochok/msg-viewer.git + """, + 'author': 'Bemade inc.', + 'website': 'https://www.bemade.org', + 'depends': [ + 'base', + 'web', + 'mail' + ], + 'data': [ + 'views/msg_viewer_views.xml', + ], + 'assets': { + 'web.assets_backend': [ + 'msg_viewer/static/src/js/msg_viewer.js', + 'msg_viewer/static/src/xml/msg_viewer.xml', + # 'msg_viewer/static/src/css/msg_viewer.css', + ], + 'msg_viewer.assets': [ + # MSG Viewer library files + 'msg_viewer/static/src/lib/msg-viewer.js', + 'msg_viewer/static/src/lib/scripts/**/*', + 'msg_viewer/static/src/lib/components/**/*', + 'msg_viewer/static/src/lib/styles/**/*', + ], + 'web.assets_qweb': [ + 'msg_viewer/static/src/xml/msg_viewer.xml', + ], + }, + 'installable': True, + 'auto_install': False, + 'license': 'LGPL-3', +} \ No newline at end of file diff --git a/msg_viewer/controllers/__init__.py b/msg_viewer/controllers/__init__.py new file mode 100644 index 0000000..12a7e52 --- /dev/null +++ b/msg_viewer/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/msg_viewer/controllers/main.py b/msg_viewer/controllers/main.py new file mode 100644 index 0000000..91c961e --- /dev/null +++ b/msg_viewer/controllers/main.py @@ -0,0 +1,88 @@ +import base64 +import logging +import os +from contextlib import nullcontext + +from odoo import http +from odoo.exceptions import AccessError, UserError +from odoo.http import request, Response, Stream +from odoo.tools import str2bool, file_open + +_logger = logging.getLogger(__name__) + +class MsgViewer(http.Controller): + @http.route([ + '/msg_viewer/content', + '/msg_viewer/content/', + '/msg_viewer/content//', + '/msg_viewer/content/', + '/msg_viewer/content//', + '/msg_viewer/content///', + '/msg_viewer/content////', + ], type='http', auth='public', readonly=True) + def msg_content(self, xmlid=None, model='ir.attachment', id=None, field='raw', + filename=None, filename_field='name', download=False, access_token=None): + """Serve the MSG file content. + + This endpoint serves the raw MSG file which will be processed by the msg-viewer JavaScript library. + """ + try: + record = request.env['ir.binary']._find_record(xmlid, model, id and int(id), access_token, field=field) + + # Get the binary content + content = record[field] + if not content: + return request.not_found() + + # Convert base64 to binary if needed + if isinstance(content, str): + content = base64.b64decode(content) + + # Create a stream to serve the file + stream = Stream.from_bytes(content) + stream.type = 'application/vnd.ms-outlook' # MIME type for .msg files + + return stream.get_response(as_attachment=str2bool(download)) + + except (AccessError, UserError): + return request.not_found() + except Exception as e: + _logger.exception("Error while serving MSG file") + return Response( + f"Error serving MSG file: {str(e)}", + status=500 + ) + + @http.route('/msg_viewer/content', type='http', auth='user') + def get_msg_content(self, model, field, id, **kwargs): + """Get the content of a MSG file.""" + record = request.env[model].browse(int(id)) + if not record.exists() or not hasattr(record, field): + return Response(status=404) + + field_value = getattr(record, field) + if not field_value: + return Response(status=404) + + return Response( + field_value, + headers=[ + ('Content-Type', 'application/vnd.ms-outlook'), + ('Content-Disposition', f'inline; filename="{record.name}"'), + ] + ) + + @http.route('/msg_viewer/static/lib/', type='http', auth='public') + def get_static_lib(self, path): + """Serve static files from the lib directory.""" + addon_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + file_path = os.path.join(addon_path, 'static', 'src', 'lib', path) + + try: + with file_open(file_path, 'rb') as f: + content = f.read() + except (IOError, OSError): + return Response(status=404) + + content_type = 'text/javascript' if path.endswith('.js') else 'text/css' if path.endswith('.css') else 'text/html' + return Response(content, content_type=content_type) diff --git a/msg_viewer/models/__init__.py b/msg_viewer/models/__init__.py new file mode 100644 index 0000000..aaf38a1 --- /dev/null +++ b/msg_viewer/models/__init__.py @@ -0,0 +1 @@ +from . import ir_attachment diff --git a/msg_viewer/models/ir_attachment.py b/msg_viewer/models/ir_attachment.py new file mode 100644 index 0000000..c8b26b8 --- /dev/null +++ b/msg_viewer/models/ir_attachment.py @@ -0,0 +1,26 @@ +from odoo import models, fields, api + +class IrAttachment(models.Model): + _inherit = 'ir.attachment' + + is_msg_file = fields.Boolean( + string='Is MSG File', + compute='_compute_is_msg_file', + store=True, + help='Technical field to identify MSG files' + ) + + @api.depends('mimetype', 'name') + def _compute_is_msg_file(self): + for attachment in self: + attachment.is_msg_file = ( + attachment.mimetype == 'application/vnd.ms-outlook' or + (attachment.name and attachment.name.lower().endswith('.msg')) + ) + + def _get_mimetype(self): + """Override to add MSG file mimetype detection.""" + mimetype = super()._get_mimetype() + if not mimetype and self.name and self.name.lower().endswith('.msg'): + return 'application/vnd.ms-outlook' + return mimetype diff --git a/msg_viewer/static/src/css/msg_viewer.css b/msg_viewer/static/src/css/msg_viewer.css new file mode 100644 index 0000000..667440c --- /dev/null +++ b/msg_viewer/static/src/css/msg_viewer.css @@ -0,0 +1,12 @@ +.o_field_msg_viewer_container { + position: relative; + width: 100%; + height: 600px; +} + +.o_field_msg_viewer_container iframe { + width: 100%; + height: 100%; + border: 1px solid #dee2e6; + border-radius: 4px; +} diff --git a/msg_viewer/static/src/js/msg_viewer.js b/msg_viewer/static/src/js/msg_viewer.js new file mode 100644 index 0000000..c82fdae --- /dev/null +++ b/msg_viewer/static/src/js/msg_viewer.js @@ -0,0 +1,88 @@ +/** @odoo-module */ + +import { registry } from "@web/core/registry"; +import { _t } from "@web/core/l10n/translation"; +import { standardFieldProps } from "@web/views/fields/standard_field_props"; +import { FileUploader } from "@web/views/fields/file_handler"; +import { Component, onWillUpdateProps, useState } from "@odoo/owl"; +import { url } from "@web/core/utils/urls"; +import { useService } from "@web/core/utils/hooks"; + +class MsgViewerField extends Component { + static template = "web.MsgViewerField"; + static components = { + FileUploader, + }; + static props = { + ...standardFieldProps, + }; + + setup() { + this.notification = useService("notification"); + this.state = useState({ + isValid: true, + objectUrl: "", + showViewer: false, + }); + onWillUpdateProps((nextProps) => { + if (nextProps.readonly) { + this.state.objectUrl = ""; + this.state.showViewer = false; + } + }); + } + + get url() { + if (!this.state.isValid || !this.props.value) { + return null; + } + const file = encodeURIComponent( + this.state.objectUrl || + url("/msg_viewer/content", { + model: this.props.record.resModel, + field: this.props.name, + id: this.props.record.resId, + }) + ); + return `/msg_viewer/static/src/lib/index.html?file=${file}`; + } + + async onFileUploaded(file) { + this.state.objectUrl = URL.createObjectURL(file); + await this.props.update(file); + } + + async onFileRemove() { + if (this.state.objectUrl) { + URL.revokeObjectURL(this.state.objectUrl); + this.state.objectUrl = ""; + } + this.state.showViewer = false; + await this.props.update(false); + } + + openMsgViewer() { + this.state.showViewer = true; + } + + onLoadFailed() { + this.notification.add( + _t("Could not display the selected MSG file."), + { + type: "danger", + } + ); + this.state.isValid = false; + } +} + +export const msgViewerField = { + component: MsgViewerField, + displayName: _t("MSG Viewer"), + supportedTypes: ["binary"], + extractProps: ({ props }) => ({ + acceptedFileExtensions: ".msg", + }), +}; + +registry.category("fields").add("msg_viewer", msgViewerField); diff --git a/msg_viewer/static/src/lib/components/attachment/index.html b/msg_viewer/static/src/lib/components/attachment/index.html new file mode 100644 index 0000000..2c3f81e --- /dev/null +++ b/msg_viewer/static/src/lib/components/attachment/index.html @@ -0,0 +1,12 @@ + + + + +
+ {{ name }} + {{ size }} +
+
\ No newline at end of file diff --git a/msg_viewer/static/src/lib/components/attachment/index.ts b/msg_viewer/static/src/lib/components/attachment/index.ts new file mode 100644 index 0000000..51ff44b --- /dev/null +++ b/msg_viewer/static/src/lib/components/attachment/index.ts @@ -0,0 +1,30 @@ +import type { Message } from "../../scripts/msg/types/message"; +import { bytesWithUnits } from "../../scripts/utils/file-size-util"; +import { createFragmentFromTemplate } from "../../scripts/utils/html-template-util"; +import template from "./index.html" with { type: "text" }; + +export function attachmentsFragment(message: Message): DocumentFragment { + const attachments = message.attachments + .filter(attachment => attachment.content) + .map(attachment => { + const blob = new Blob([attachment.content], { type: 'application/octet-stream' }); + + const model: AttachmentViewModel = { + name: attachment.displayName, + size: bytesWithUnits(attachment.content.byteLength).toString(), + url: URL.createObjectURL(blob) + }; + + return createFragmentFromTemplate(template, model); + }); + + const fragment = document.createDocumentFragment(); + fragment.append(...attachments); + return fragment; +} + +interface AttachmentViewModel { + name: string, + size: string, + url: string +} diff --git a/msg_viewer/static/src/lib/components/attachment/styles.css b/msg_viewer/static/src/lib/components/attachment/styles.css new file mode 100644 index 0000000..8ddd632 --- /dev/null +++ b/msg_viewer/static/src/lib/components/attachment/styles.css @@ -0,0 +1,3 @@ +.msg-attach-btn { + background-color: #1d5c56; +} diff --git a/msg_viewer/static/src/lib/components/embedded-msg/index.html b/msg_viewer/static/src/lib/components/embedded-msg/index.html new file mode 100644 index 0000000..676b64b --- /dev/null +++ b/msg_viewer/static/src/lib/components/embedded-msg/index.html @@ -0,0 +1,9 @@ + \ No newline at end of file diff --git a/msg_viewer/static/src/lib/components/embedded-msg/index.ts b/msg_viewer/static/src/lib/components/embedded-msg/index.ts new file mode 100644 index 0000000..eec2043 --- /dev/null +++ b/msg_viewer/static/src/lib/components/embedded-msg/index.ts @@ -0,0 +1,26 @@ +import type { DirectoryEntry } from "../../scripts/msg/compound-file/directory/types/directory-entry"; +import type { Message } from "../../scripts/msg/types/message"; +import { createFragmentFromTemplate } from "../../scripts/utils/html-template-util"; +import template from "./index.html" with { type: "text" }; + +export function embeddedMsgsFragment(message: Message, onClick: (entry: DirectoryEntry) => void): DocumentFragment { + const elements = message.attachments + .filter(attachment => attachment.embeddedMsgObj) + .map(attachment => { + const model: EmbeddedMsgViewModel = { + name: attachment.displayName + }; + + const fragment = createFragmentFromTemplate(template, model); + fragment.children[0].addEventListener("click", () => onClick(attachment.embeddedMsgObj)); + return fragment; + }); + + const fragment = document.createDocumentFragment(); + fragment.append(...elements); + return fragment; +} + +interface EmbeddedMsgViewModel { + name: string, +} diff --git a/msg_viewer/static/src/lib/components/embedded-msg/styles.css b/msg_viewer/static/src/lib/components/embedded-msg/styles.css new file mode 100644 index 0000000..8a9fc47 --- /dev/null +++ b/msg_viewer/static/src/lib/components/embedded-msg/styles.css @@ -0,0 +1,3 @@ +.embedded-msg-btn { + background-color: rgb(74, 126, 45); +} diff --git a/msg_viewer/static/src/lib/components/error/index.html b/msg_viewer/static/src/lib/components/error/index.html new file mode 100644 index 0000000..97564de --- /dev/null +++ b/msg_viewer/static/src/lib/components/error/index.html @@ -0,0 +1 @@ +{{ error }} \ No newline at end of file diff --git a/msg_viewer/static/src/lib/components/error/index.ts b/msg_viewer/static/src/lib/components/error/index.ts new file mode 100644 index 0000000..d7dbcaf --- /dev/null +++ b/msg_viewer/static/src/lib/components/error/index.ts @@ -0,0 +1,6 @@ +import { createFragmentFromTemplate } from "../../scripts/utils/html-template-util"; +import template from "./index.html" with { type: "text" }; + +export function errorFragment(error: string): DocumentFragment { + return createFragmentFromTemplate(template, { error }); +} diff --git a/msg_viewer/static/src/lib/components/error/styles.css b/msg_viewer/static/src/lib/components/error/styles.css new file mode 100644 index 0000000..4f39912 --- /dev/null +++ b/msg_viewer/static/src/lib/components/error/styles.css @@ -0,0 +1,4 @@ +.msg-error { + padding: 20px; + color: #fe5e5e; +} \ No newline at end of file diff --git a/msg_viewer/static/src/lib/components/message/index.html b/msg_viewer/static/src/lib/components/message/index.html new file mode 100644 index 0000000..f6aedd6 --- /dev/null +++ b/msg_viewer/static/src/lib/components/message/index.html @@ -0,0 +1,33 @@ +
+
+
+

{{ title }}

+ +
+
+
+ From: {{ name }} + {{ date }} +
+
+ To:
+ Cc:
+
+
+ Attachments: +
+
+
+ Embedded .msg files: +
+
+
+
{{ rawContent }}
+
\ No newline at end of file diff --git a/msg_viewer/static/src/lib/components/message/index.ts b/msg_viewer/static/src/lib/components/message/index.ts new file mode 100644 index 0000000..60e1710 --- /dev/null +++ b/msg_viewer/static/src/lib/components/message/index.ts @@ -0,0 +1,79 @@ +import type { DirectoryEntry } from "../../scripts/msg/compound-file/directory/types/directory-entry"; +import type { Message, MessageContent } from "../../scripts/msg/types/message"; +import { createFragmentFromTemplate } from "../../scripts/utils/html-template-util"; +import { attachmentsFragment } from "../attachment"; +import { embeddedMsgsFragment } from "../embedded-msg"; +import { recipientsFragments } from "../recipient"; +import template from "./index.html" with { type: "text" }; + +export function messageFragment(message: Message, renderDir: (dir: DirectoryEntry) => void): DocumentFragment { + const [toRecip, ccRecip] = recipientsFragments(message); + + const model: MessageViewModel = { + title: message.content.subject, + name: getName(message.content), + date: getDate(message.content), + rawContent: message.content.body, + }; + + const container = createFragmentFromTemplate(template, model); + addFragmentToContainer(container, ".msg-attachs", attachmentsFragment(message), false); + addFragmentToContainer(container, ".msg-embedded-msgs", embeddedMsgsFragment(message, renderDir), true); + addFragmentToContainer(container, ".msg-recip-to", toRecip, false); + addFragmentToContainer(container, ".msg-recip-cc", ccRecip, false); + + container.querySelector(".msg-close-btn")?.addEventListener("click", (e) => { + const $btn = e.currentTarget as HTMLElement; + const $msg = $btn.closest(".msg")!; + const $container = $msg.parentElement!; + $msg.remove(); + + const lastChild = $container.lastChild as HTMLElement; + if (lastChild) { + lastChild.classList.remove("hidden"); + } + }); + + return container; +} + +function addFragmentToContainer(container: DocumentFragment, className: string, fragment: DocumentFragment, removeIfEmpty: boolean) { + const element = container.querySelector(className); + if (!element) return; + + if (fragment.children.length === 0 && removeIfEmpty) { + element.parentElement?.remove(); + return; + } + + element.replaceChildren(fragment); +} + +function getName(content: MessageContent): string { + let name = content.senderName ?? ""; + if (content.senderEmail) { + name += ` <${content.senderEmail}>`; + } + return name; +} + +function getDate(content: MessageContent): string { + return content.date?.toLocaleString('en-US', { + weekday: "short", + month: '2-digit', + day: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: false, + timeZone: "UTC", + timeZoneName: "short" + }) ?? ""; +} + +interface MessageViewModel { + title: string, + name: string, + date: string, + rawContent: string, +} diff --git a/msg_viewer/static/src/lib/components/message/styles.css b/msg_viewer/static/src/lib/components/message/styles.css new file mode 100644 index 0000000..3883250 --- /dev/null +++ b/msg_viewer/static/src/lib/components/message/styles.css @@ -0,0 +1,50 @@ +.msg { + min-height: 100%; + height: fit-content; +} + +.msg-header { + --h-padding: 16px; + + display: flex; + flex-direction: column; + gap: 16px; + padding: var(--h-padding) var(--h-padding) 0; +} + +.msg-close-btn { + padding: 0; + background-color: #b42020; +} +.msg-close-btn svg { + display: block; +} + +.msg-divider { + margin: 0px calc(-1 * var(--h-padding)); + border-bottom: 1px solid white; +} + +.msg-date { + font-size: 0.9em; +} + +.msg-recip-section { + display: grid; + grid-template-columns: auto 1fr; + grid-template-rows: 1fr auto; + column-gap: 5px; +} + +.msg-content { + background: #24262b; + padding: 15px 20px; + flex: 1; + white-space: pre-wrap; +} + +.msg-attachs { + display: flex; + flex-wrap: wrap; + gap: 7px; +} \ No newline at end of file diff --git a/msg_viewer/static/src/lib/components/recipient/index.html b/msg_viewer/static/src/lib/components/recipient/index.html new file mode 100644 index 0000000..2092881 --- /dev/null +++ b/msg_viewer/static/src/lib/components/recipient/index.html @@ -0,0 +1 @@ +{{ name }} {{ email }}; \ No newline at end of file diff --git a/msg_viewer/static/src/lib/components/recipient/index.ts b/msg_viewer/static/src/lib/components/recipient/index.ts new file mode 100644 index 0000000..65ec234 --- /dev/null +++ b/msg_viewer/static/src/lib/components/recipient/index.ts @@ -0,0 +1,48 @@ +import type { Message, Recipient } from "../../scripts/msg/types/message"; +import { createFragmentFromTemplate } from "../../scripts/utils/html-template-util"; +import template from "./index.html" with { type: "text" }; + +export function recipientsFragments(message: Message): DocumentFragment[] { + const toRecipients = toSet(message.content.toRecipients); + const ccRecipients = toSet(message.content.ccRecipients); + + const to = []; + const cc = []; + for (const recipient of message.recipients) { + if (toRecipients.has(recipient.name)) { + to.push(recipient); + } else if (ccRecipients.has(recipient.name)) { + cc.push(recipient); + } + } + + return [recipientHTML(to), recipientHTML(cc)]; +} + +function recipientHTML(recipients: Recipient[]): DocumentFragment { + const recipFragments = recipients.map(recipient => { + const model: RecipientViewModel = { + name: recipient.name, + email: recipient.email ? `<${recipient.email}>` : "" + }; + + return createFragmentFromTemplate(template, model); + }); + + const fragment = document.createDocumentFragment(); + fragment.append(...recipFragments); + return fragment; +} + +function toSet(recipStr: string) { + if (!recipStr) return new Set(); + + return new Set(recipStr.endsWith('\x00') + ? recipStr.slice(0, -1).split("; ") + : recipStr.split("; ")); +} + +interface RecipientViewModel { + name: string, + email: string +} \ No newline at end of file diff --git a/msg_viewer/static/src/lib/components/recipient/styles.css b/msg_viewer/static/src/lib/components/recipient/styles.css new file mode 100644 index 0000000..e69de29 diff --git a/msg_viewer/static/src/lib/components/styles.css b/msg_viewer/static/src/lib/components/styles.css new file mode 100644 index 0000000..05ad88d --- /dev/null +++ b/msg_viewer/static/src/lib/components/styles.css @@ -0,0 +1,5 @@ +@import "./message/styles.css"; +@import "./recipient/styles.css"; +@import "./attachment/styles.css"; +@import "./embedded-msg/styles.css"; +@import "./error/styles.css"; diff --git a/msg_viewer/static/src/lib/favicon.ico b/msg_viewer/static/src/lib/favicon.ico new file mode 100644 index 0000000..64ecee3 Binary files /dev/null and b/msg_viewer/static/src/lib/favicon.ico differ diff --git a/msg_viewer/static/src/lib/index.html b/msg_viewer/static/src/lib/index.html new file mode 100644 index 0000000..6780d42 --- /dev/null +++ b/msg_viewer/static/src/lib/index.html @@ -0,0 +1,50 @@ + + + + + + + + + Free in-browser .msg viewer + + + + + + + +
+
+
+

.msg viewer

+

This tool allows you to easily view the content of the Outlook .msg file.

+
    +
  • Free
  • +
  • In browser
  • +
  • Fast
  • +
  • Secure
  • +
  • No server
  • +
  • No data sharing
  • +
+ + +
+ +
+ +
+
+ + + + \ No newline at end of file diff --git a/msg_viewer/static/src/lib/msg-viewer-main/.gitignore b/msg_viewer/static/src/lib/msg-viewer-main/.gitignore new file mode 100644 index 0000000..8805626 --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/.gitignore @@ -0,0 +1,184 @@ +# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore + +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Caches + +.cache + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store + +# Contains input and output resources +/resources + +# Contains built logic +/build + +# Contains Documentation which might be large in some cases +/documentation \ No newline at end of file diff --git a/msg_viewer/static/src/lib/msg-viewer-main/LICENSE b/msg_viewer/static/src/lib/msg-viewer-main/LICENSE new file mode 100644 index 0000000..5b1eebd --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Markiian Karpa + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/msg_viewer/static/src/lib/msg-viewer-main/README.md b/msg_viewer/static/src/lib/msg-viewer-main/README.md new file mode 100644 index 0000000..904f779 --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/README.md @@ -0,0 +1,36 @@ +
+ Icon +

https://msg-viewer.pages.dev/

+

+ +### Description +This tool allows you to read Outlook `.msg` file in your browser without sending any data to an external server. It is extremely fast, works on any device as long as you have an Internet connection to open the page. + +### Motivation +At work, I needed to read a .msg file. Since I use a Mac, I quickly realized that this task wasn't straightforward. The official Outlook app on Mac doesn’t support .msg files and instead expects .eml files. Due to sensitive data and company policies, I couldn't download any external software or use a website that uploads files to an external server. That’s why I decided to create my own solution to read the file. Here are the key functional requirements I set for myself while working on this project: + - It must display the file’s content; + - It must not rely on any server; + - It must be fast. + +### Features + - Free (Open Source); + - No Server, no data sharing; + - Extremely Fast; + - Works on any device that can open the page. + +### Development +#### Build +The project uses Bun. To build it simply run: +``` +bun .\build.ts +``` +The command will put a final HTML in `build` folder. + +#### Branches +The page is hosted via Cloudfare. There are multiple branches for the preview and production. +Branches: + - dev - for development and corresponds to Preview in Cloudfare + - main - for production and corresponds to Production in Cloudfare + +### Support +If you wish to support me you can by me a [coffee](https://buymeacoffee.com/markian98f). diff --git a/msg_viewer/static/src/lib/msg-viewer-main/build.ts b/msg_viewer/static/src/lib/msg-viewer-main/build.ts new file mode 100644 index 0000000..670d401 --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/build.ts @@ -0,0 +1,78 @@ +import html from 'bun-plugin-html'; +import * as fs from 'fs/promises'; +import * as path from 'path'; + +const SOURCE_DIR = "./lib"; +const DEST_DIR = "./build"; + +try { + await clearDestDir(); + await createDestDir(); + await copyResourceFiles(); + await build(); +} catch(err) { + console.error("Failed to build. Error: ", err); +} + +async function copyResourceFiles() { + const resourceDir = path.join(SOURCE_DIR, "resources"); + console.log(`Copying resources from "${resourceDir}" to "${DEST_DIR}".`); + + try { + const files = await fs.readdir(resourceDir); + for (const file of files) { + try { + await fs.copyFile(path.join(resourceDir, file), path.join(DEST_DIR, file)); + console.log(`File "${file}" copied successfully!`); + } catch(err) { + console.error(`Failed to copy file "${file}".`); + throw err; + } + } + + console.log("Resource files copied successfully!\n"); + } catch (err) { + console.error(`Failed to read resource directory "${resourceDir}".`); + throw err; + } +} + +async function build() { + console.log("Building..."); + + try { + await Bun.build({ + entrypoints: ["./lib/index.html"], + outdir: DEST_DIR, + minify: true, + plugins: [ + html({ inline: true }), + ], + }); + console.log("Build succeeded!\n"); + } catch(err) { + console.error("Failed to build."); + throw err; + } +} + +async function clearDestDir() { + console.log(`Clearing destination folder "${DEST_DIR}".`); + try { + await fs.rm(DEST_DIR, { recursive: true, force: true }); + console.log(`Destination folder "${DEST_DIR}" cleared successfully!\n`); + } catch(err) { + console.error(`Failed to clear destination folder "${DEST_DIR}". Error: `, err); + throw err; + } +} + +async function createDestDir(){ + console.log(`Creating destination folder "${DEST_DIR}".`); + try { + await fs.mkdir(DEST_DIR, { recursive: true }); + console.log(`Successfully created destination folder "${DEST_DIR}".\n`); + } catch(err) { + console.error(`Failed to create destination folder "${DEST_DIR}".`); + } +} \ No newline at end of file diff --git a/msg_viewer/static/src/lib/msg-viewer-main/bun.lockb b/msg_viewer/static/src/lib/msg-viewer-main/bun.lockb new file mode 100644 index 0000000..0dc6c0f Binary files /dev/null and b/msg_viewer/static/src/lib/msg-viewer-main/bun.lockb differ diff --git a/msg_viewer/static/src/lib/msg-viewer-main/declarations.d.ts b/msg_viewer/static/src/lib/msg-viewer-main/declarations.d.ts new file mode 100644 index 0000000..15ca964 --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/declarations.d.ts @@ -0,0 +1,8 @@ +declare module "*.html" { + const content: string; + export default content; +} + +interface Window { + gtag: (event: string, type: string, args: { [key: string]: any }) => void; +} \ No newline at end of file diff --git a/msg_viewer/static/src/lib/msg-viewer-main/icon.svg b/msg_viewer/static/src/lib/msg-viewer-main/icon.svg new file mode 100644 index 0000000..732c93d --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/msg_viewer/static/src/lib/msg-viewer-main/jsconfig.json b/msg_viewer/static/src/lib/msg-viewer-main/jsconfig.json new file mode 100644 index 0000000..238655f --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/jsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + // Enable latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +} diff --git a/msg_viewer/static/src/lib/msg-viewer-main/lib/components/attachment/index.html b/msg_viewer/static/src/lib/msg-viewer-main/lib/components/attachment/index.html new file mode 100644 index 0000000..2c3f81e --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/lib/components/attachment/index.html @@ -0,0 +1,12 @@ + + + + +
+ {{ name }} + {{ size }} +
+
\ No newline at end of file diff --git a/msg_viewer/static/src/lib/msg-viewer-main/lib/components/attachment/index.ts b/msg_viewer/static/src/lib/msg-viewer-main/lib/components/attachment/index.ts new file mode 100644 index 0000000..51ff44b --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/lib/components/attachment/index.ts @@ -0,0 +1,30 @@ +import type { Message } from "../../scripts/msg/types/message"; +import { bytesWithUnits } from "../../scripts/utils/file-size-util"; +import { createFragmentFromTemplate } from "../../scripts/utils/html-template-util"; +import template from "./index.html" with { type: "text" }; + +export function attachmentsFragment(message: Message): DocumentFragment { + const attachments = message.attachments + .filter(attachment => attachment.content) + .map(attachment => { + const blob = new Blob([attachment.content], { type: 'application/octet-stream' }); + + const model: AttachmentViewModel = { + name: attachment.displayName, + size: bytesWithUnits(attachment.content.byteLength).toString(), + url: URL.createObjectURL(blob) + }; + + return createFragmentFromTemplate(template, model); + }); + + const fragment = document.createDocumentFragment(); + fragment.append(...attachments); + return fragment; +} + +interface AttachmentViewModel { + name: string, + size: string, + url: string +} diff --git a/msg_viewer/static/src/lib/msg-viewer-main/lib/components/attachment/styles.css b/msg_viewer/static/src/lib/msg-viewer-main/lib/components/attachment/styles.css new file mode 100644 index 0000000..8ddd632 --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/lib/components/attachment/styles.css @@ -0,0 +1,3 @@ +.msg-attach-btn { + background-color: #1d5c56; +} diff --git a/msg_viewer/static/src/lib/msg-viewer-main/lib/components/embedded-msg/index.html b/msg_viewer/static/src/lib/msg-viewer-main/lib/components/embedded-msg/index.html new file mode 100644 index 0000000..676b64b --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/lib/components/embedded-msg/index.html @@ -0,0 +1,9 @@ + \ No newline at end of file diff --git a/msg_viewer/static/src/lib/msg-viewer-main/lib/components/embedded-msg/index.ts b/msg_viewer/static/src/lib/msg-viewer-main/lib/components/embedded-msg/index.ts new file mode 100644 index 0000000..eec2043 --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/lib/components/embedded-msg/index.ts @@ -0,0 +1,26 @@ +import type { DirectoryEntry } from "../../scripts/msg/compound-file/directory/types/directory-entry"; +import type { Message } from "../../scripts/msg/types/message"; +import { createFragmentFromTemplate } from "../../scripts/utils/html-template-util"; +import template from "./index.html" with { type: "text" }; + +export function embeddedMsgsFragment(message: Message, onClick: (entry: DirectoryEntry) => void): DocumentFragment { + const elements = message.attachments + .filter(attachment => attachment.embeddedMsgObj) + .map(attachment => { + const model: EmbeddedMsgViewModel = { + name: attachment.displayName + }; + + const fragment = createFragmentFromTemplate(template, model); + fragment.children[0].addEventListener("click", () => onClick(attachment.embeddedMsgObj)); + return fragment; + }); + + const fragment = document.createDocumentFragment(); + fragment.append(...elements); + return fragment; +} + +interface EmbeddedMsgViewModel { + name: string, +} diff --git a/msg_viewer/static/src/lib/msg-viewer-main/lib/components/embedded-msg/styles.css b/msg_viewer/static/src/lib/msg-viewer-main/lib/components/embedded-msg/styles.css new file mode 100644 index 0000000..8a9fc47 --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/lib/components/embedded-msg/styles.css @@ -0,0 +1,3 @@ +.embedded-msg-btn { + background-color: rgb(74, 126, 45); +} diff --git a/msg_viewer/static/src/lib/msg-viewer-main/lib/components/error/index.html b/msg_viewer/static/src/lib/msg-viewer-main/lib/components/error/index.html new file mode 100644 index 0000000..97564de --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/lib/components/error/index.html @@ -0,0 +1 @@ +{{ error }} \ No newline at end of file diff --git a/msg_viewer/static/src/lib/msg-viewer-main/lib/components/error/index.ts b/msg_viewer/static/src/lib/msg-viewer-main/lib/components/error/index.ts new file mode 100644 index 0000000..d7dbcaf --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/lib/components/error/index.ts @@ -0,0 +1,6 @@ +import { createFragmentFromTemplate } from "../../scripts/utils/html-template-util"; +import template from "./index.html" with { type: "text" }; + +export function errorFragment(error: string): DocumentFragment { + return createFragmentFromTemplate(template, { error }); +} diff --git a/msg_viewer/static/src/lib/msg-viewer-main/lib/components/error/styles.css b/msg_viewer/static/src/lib/msg-viewer-main/lib/components/error/styles.css new file mode 100644 index 0000000..4f39912 --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/lib/components/error/styles.css @@ -0,0 +1,4 @@ +.msg-error { + padding: 20px; + color: #fe5e5e; +} \ No newline at end of file diff --git a/msg_viewer/static/src/lib/msg-viewer-main/lib/components/message/index.html b/msg_viewer/static/src/lib/msg-viewer-main/lib/components/message/index.html new file mode 100644 index 0000000..f6aedd6 --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/lib/components/message/index.html @@ -0,0 +1,33 @@ +
+
+
+

{{ title }}

+ +
+
+
+ From: {{ name }} + {{ date }} +
+
+ To:
+ Cc:
+
+
+ Attachments: +
+
+
+ Embedded .msg files: +
+
+
+
{{ rawContent }}
+
\ No newline at end of file diff --git a/msg_viewer/static/src/lib/msg-viewer-main/lib/components/message/index.ts b/msg_viewer/static/src/lib/msg-viewer-main/lib/components/message/index.ts new file mode 100644 index 0000000..60e1710 --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/lib/components/message/index.ts @@ -0,0 +1,79 @@ +import type { DirectoryEntry } from "../../scripts/msg/compound-file/directory/types/directory-entry"; +import type { Message, MessageContent } from "../../scripts/msg/types/message"; +import { createFragmentFromTemplate } from "../../scripts/utils/html-template-util"; +import { attachmentsFragment } from "../attachment"; +import { embeddedMsgsFragment } from "../embedded-msg"; +import { recipientsFragments } from "../recipient"; +import template from "./index.html" with { type: "text" }; + +export function messageFragment(message: Message, renderDir: (dir: DirectoryEntry) => void): DocumentFragment { + const [toRecip, ccRecip] = recipientsFragments(message); + + const model: MessageViewModel = { + title: message.content.subject, + name: getName(message.content), + date: getDate(message.content), + rawContent: message.content.body, + }; + + const container = createFragmentFromTemplate(template, model); + addFragmentToContainer(container, ".msg-attachs", attachmentsFragment(message), false); + addFragmentToContainer(container, ".msg-embedded-msgs", embeddedMsgsFragment(message, renderDir), true); + addFragmentToContainer(container, ".msg-recip-to", toRecip, false); + addFragmentToContainer(container, ".msg-recip-cc", ccRecip, false); + + container.querySelector(".msg-close-btn")?.addEventListener("click", (e) => { + const $btn = e.currentTarget as HTMLElement; + const $msg = $btn.closest(".msg")!; + const $container = $msg.parentElement!; + $msg.remove(); + + const lastChild = $container.lastChild as HTMLElement; + if (lastChild) { + lastChild.classList.remove("hidden"); + } + }); + + return container; +} + +function addFragmentToContainer(container: DocumentFragment, className: string, fragment: DocumentFragment, removeIfEmpty: boolean) { + const element = container.querySelector(className); + if (!element) return; + + if (fragment.children.length === 0 && removeIfEmpty) { + element.parentElement?.remove(); + return; + } + + element.replaceChildren(fragment); +} + +function getName(content: MessageContent): string { + let name = content.senderName ?? ""; + if (content.senderEmail) { + name += ` <${content.senderEmail}>`; + } + return name; +} + +function getDate(content: MessageContent): string { + return content.date?.toLocaleString('en-US', { + weekday: "short", + month: '2-digit', + day: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: false, + timeZone: "UTC", + timeZoneName: "short" + }) ?? ""; +} + +interface MessageViewModel { + title: string, + name: string, + date: string, + rawContent: string, +} diff --git a/msg_viewer/static/src/lib/msg-viewer-main/lib/components/message/styles.css b/msg_viewer/static/src/lib/msg-viewer-main/lib/components/message/styles.css new file mode 100644 index 0000000..3883250 --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/lib/components/message/styles.css @@ -0,0 +1,50 @@ +.msg { + min-height: 100%; + height: fit-content; +} + +.msg-header { + --h-padding: 16px; + + display: flex; + flex-direction: column; + gap: 16px; + padding: var(--h-padding) var(--h-padding) 0; +} + +.msg-close-btn { + padding: 0; + background-color: #b42020; +} +.msg-close-btn svg { + display: block; +} + +.msg-divider { + margin: 0px calc(-1 * var(--h-padding)); + border-bottom: 1px solid white; +} + +.msg-date { + font-size: 0.9em; +} + +.msg-recip-section { + display: grid; + grid-template-columns: auto 1fr; + grid-template-rows: 1fr auto; + column-gap: 5px; +} + +.msg-content { + background: #24262b; + padding: 15px 20px; + flex: 1; + white-space: pre-wrap; +} + +.msg-attachs { + display: flex; + flex-wrap: wrap; + gap: 7px; +} \ No newline at end of file diff --git a/msg_viewer/static/src/lib/msg-viewer-main/lib/components/recipient/index.html b/msg_viewer/static/src/lib/msg-viewer-main/lib/components/recipient/index.html new file mode 100644 index 0000000..2092881 --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/lib/components/recipient/index.html @@ -0,0 +1 @@ +{{ name }} {{ email }}; \ No newline at end of file diff --git a/msg_viewer/static/src/lib/msg-viewer-main/lib/components/recipient/index.ts b/msg_viewer/static/src/lib/msg-viewer-main/lib/components/recipient/index.ts new file mode 100644 index 0000000..65ec234 --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/lib/components/recipient/index.ts @@ -0,0 +1,48 @@ +import type { Message, Recipient } from "../../scripts/msg/types/message"; +import { createFragmentFromTemplate } from "../../scripts/utils/html-template-util"; +import template from "./index.html" with { type: "text" }; + +export function recipientsFragments(message: Message): DocumentFragment[] { + const toRecipients = toSet(message.content.toRecipients); + const ccRecipients = toSet(message.content.ccRecipients); + + const to = []; + const cc = []; + for (const recipient of message.recipients) { + if (toRecipients.has(recipient.name)) { + to.push(recipient); + } else if (ccRecipients.has(recipient.name)) { + cc.push(recipient); + } + } + + return [recipientHTML(to), recipientHTML(cc)]; +} + +function recipientHTML(recipients: Recipient[]): DocumentFragment { + const recipFragments = recipients.map(recipient => { + const model: RecipientViewModel = { + name: recipient.name, + email: recipient.email ? `<${recipient.email}>` : "" + }; + + return createFragmentFromTemplate(template, model); + }); + + const fragment = document.createDocumentFragment(); + fragment.append(...recipFragments); + return fragment; +} + +function toSet(recipStr: string) { + if (!recipStr) return new Set(); + + return new Set(recipStr.endsWith('\x00') + ? recipStr.slice(0, -1).split("; ") + : recipStr.split("; ")); +} + +interface RecipientViewModel { + name: string, + email: string +} \ No newline at end of file diff --git a/msg_viewer/static/src/lib/msg-viewer-main/lib/components/recipient/styles.css b/msg_viewer/static/src/lib/msg-viewer-main/lib/components/recipient/styles.css new file mode 100644 index 0000000..e69de29 diff --git a/msg_viewer/static/src/lib/msg-viewer-main/lib/components/styles.css b/msg_viewer/static/src/lib/msg-viewer-main/lib/components/styles.css new file mode 100644 index 0000000..05ad88d --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/lib/components/styles.css @@ -0,0 +1,5 @@ +@import "./message/styles.css"; +@import "./recipient/styles.css"; +@import "./attachment/styles.css"; +@import "./embedded-msg/styles.css"; +@import "./error/styles.css"; diff --git a/msg_viewer/static/src/lib/msg-viewer-main/lib/favicon.ico b/msg_viewer/static/src/lib/msg-viewer-main/lib/favicon.ico new file mode 100644 index 0000000..64ecee3 Binary files /dev/null and b/msg_viewer/static/src/lib/msg-viewer-main/lib/favicon.ico differ diff --git a/msg_viewer/static/src/lib/msg-viewer-main/lib/index.html b/msg_viewer/static/src/lib/msg-viewer-main/lib/index.html new file mode 100644 index 0000000..6780d42 --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/lib/index.html @@ -0,0 +1,50 @@ + + + + + + + + + Free in-browser .msg viewer + + + + + + + +
+
+
+

.msg viewer

+

This tool allows you to easily view the content of the Outlook .msg file.

+
    +
  • Free
  • +
  • In browser
  • +
  • Fast
  • +
  • Secure
  • +
  • No server
  • +
  • No data sharing
  • +
+ + +
+ +
+ +
+
+ + + + \ No newline at end of file diff --git a/msg_viewer/static/src/lib/msg-viewer-main/lib/resources/google331b65fa04565532.html b/msg_viewer/static/src/lib/msg-viewer-main/lib/resources/google331b65fa04565532.html new file mode 100644 index 0000000..b7121c2 --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/lib/resources/google331b65fa04565532.html @@ -0,0 +1 @@ +google-site-verification: google331b65fa04565532.html \ No newline at end of file diff --git a/msg_viewer/static/src/lib/msg-viewer-main/lib/resources/robots.txt b/msg_viewer/static/src/lib/msg-viewer-main/lib/resources/robots.txt new file mode 100644 index 0000000..3dcd2c3 --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/lib/resources/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: \ No newline at end of file diff --git a/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/index.ts b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/index.ts new file mode 100644 index 0000000..10d90b3 --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/index.ts @@ -0,0 +1,63 @@ +import { messageFragment } from "../components/message"; +import { errorFragment } from "../components/error"; +import type { Message } from "./msg/types/message"; +import { parse, parseDir } from "@molotochok/msg-viewer"; + +const $file = document.getElementById("file")!; + +$file.addEventListener("change", async (event) => { + const target = event.target as HTMLInputElement; + if (target?.files?.length === 0) return; + updateMessage(target.files!); +}); + +// To reset the file input +$file.addEventListener("click", (event) => (event.target as HTMLInputElement).value = ""); + + +const target = document.documentElement; +target.addEventListener("dragover", (event) => event.preventDefault()); +target.addEventListener("drop", (event) => { + event.preventDefault(); + + const files = event.dataTransfer!.files; + if (files.length == 0) return; + if (!files[0].name.endsWith(".msg")) return; + + const $file = document.getElementById("file")! as HTMLInputElement; + $file.files = files; + updateMessage(files); +}); + +async function updateMessage(files: FileList) { + const arrayBuffer = await files[0].arrayBuffer(); + const $msg = document.getElementById("msg")!; + renderMessage($msg, + () => parse(new DataView(arrayBuffer)), + (fragment) => $msg.replaceChildren(fragment) + ); +} + +function renderMessage($msg: HTMLElement, getMessage: () => Message, updateDom: (fragment: DocumentFragment) => void) { + let fragment: DocumentFragment; + try { + const message = getMessage(); + fragment = messageFragment(message, dir => { + renderMessage($msg, + () => parseDir(message.file, dir), + (fragment) => { + for (let i = 0; i < $msg.children.length; i++) { + const child = $msg.children[i] as HTMLElement; + child.classList.add("hidden"); + }; + $msg.appendChild(fragment) + } + ); + }); + } catch (e) { + window.gtag('event', 'exception', { 'description': e, 'fatal': true }); + fragment = errorFragment(`An error occured during the parsing of the .msg file. Error: ${e}`); + } + + updateDom(fragment); +} \ No newline at end of file diff --git a/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/README.md b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/README.md new file mode 100644 index 0000000..c627316 --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/README.md @@ -0,0 +1,37 @@ +
+ Icon +

https://msg-viewer.pages.dev/

+

+ +### Description +This library allows you to read Outlook `.msg` file in browser. + +### Install +Simply run this command in terminal: +``` +npm i @molotochok/msg-viewer +``` + +### Usage +First import the library: + +```js +import { parse } from "@molotochok/msg-viewer"; +``` + +Then parse the input array buffer: +```js +const message = parse(new DataView(arrayBuffer)); +``` + +The result is an object that contains relevant information about the message. It's structure is defined [here](https://github.com/molotochok/msg-viewer/blob/main/lib/scripts/msg/types/message.d.ts#L1). + +### Example +Refer to this [page](https://github.com/molotochok/msg-viewer/blob/main/lib/scripts/index.ts#L34) for the example usage of this library in the live experience. + + +### Repository +You can find the source code on [Github](https://github.com/molotochok/msg-viewer). + +### Support +If you wish to support me you can by me a [coffee](https://buymeacoffee.com/markian98f). diff --git a/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/compound-file/compound-file.ts b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/compound-file/compound-file.ts new file mode 100644 index 0000000..c75c4cf --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/compound-file/compound-file.ts @@ -0,0 +1,84 @@ +import { getDifat } from "./difat"; +import { Directory } from "./directory/directory"; +import type { DirectoryEntry } from "./directory/types/directory-entry"; +import { getFat } from "./fat"; +import { getHeader, type Header } from "./header"; +import { getMiniFat } from "./mini-fat"; +import { streamSectorOffset } from "./util"; + +export class CompoundFile { + constructor( + public readonly view: DataView, + public readonly header: Header, + public readonly difat: number[], + public readonly fat: number[], + public readonly miniFat: number[], + public readonly directory: Directory, + ) {} + + static create(view: DataView) { + const header = getHeader(view); + const difat = getDifat(view, header); + const fat = getFat(view, header, difat); + const miniFat = getMiniFat(view, header, fat); + const directory = Directory.getDirectory(view, header, fat); + + return new CompoundFile(view, header, difat, fat, miniFat, directory); + } + + readStream( + entry: DirectoryEntry, + getDataAction: (offset: number, bytesToRead: number) => void, + entrySize?: number, + getHeaderAction?: (offset: number) => number + ) { + let sector = entry.startingSectorLocation; + if (sector >= 0xFFFFFFFE) return; + + let sectorSize = this.header.sectorSize; + let fat = this.fat; + + if (entry.streamSize < this.header.miniStreamCutOffSize) { + sectorSize = this.header.miniSectorSize; + fat = this.miniFat; + } + + let offset = streamSectorOffset(sector, this.header, entry.streamSize, this.directory.miniStreamLocations); + let initialOffset = offset; + + const headerSize = getHeaderAction ? getHeaderAction(offset) : 0; + let streamSize = entry.streamSize - BigInt(headerSize); + offset += headerSize; + entrySize = entrySize ?? Math.min(Number(streamSize), this.header.miniSectorSize); + + while(streamSize > 0) { + if (offset - initialOffset >= sectorSize) { + sector = fat[sector]; + if (sector >= 0xFFFFFFFE) break; + + offset = streamSectorOffset(sector, this.header, entry.streamSize, this.directory.miniStreamLocations); + initialOffset = offset; + } + + let bytes = Math.min(entrySize, Number(streamSize)); + getDataAction(offset, bytes); + + streamSize -= BigInt(bytes); + offset += bytes; + } + } + + toString(): string { + const obj = { + header: this.header, + difat: this.difat, + fat: this.fat, + miniFat: + this.miniFat, + directory: + this.directory + }; + + return JSON.stringify(obj, (_, v) => typeof v === 'bigint' ? v.toString() : v, "\t"); + } +} \ No newline at end of file diff --git a/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/compound-file/constants/text-decoder.ts b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/compound-file/constants/text-decoder.ts new file mode 100644 index 0000000..2e424ee --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/compound-file/constants/text-decoder.ts @@ -0,0 +1 @@ +export const TEXT_DECODER = new TextDecoder("UTF-16LE"); \ No newline at end of file diff --git a/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/compound-file/difat.ts b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/compound-file/difat.ts new file mode 100644 index 0000000..bc4b8ff --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/compound-file/difat.ts @@ -0,0 +1,34 @@ +import type { Header } from "./header"; +import { sectorOffset } from "./util"; + +/** + * Returns an array of FAT Sector Locations which specify the FAT sector number. + * DIFAT is used to find all FAT sectors. + */ +export function getDifat(view: DataView, header: Header): number[] { + // - If Header Major Version is 3, there MUST be 127 fields specified to fill a 512-byte sector minus the "Next DIFAT Sector Location" field. + // - If Header Major Version is 4, there MUST be 1,023 fields specified to fill a 4,096-byte sector minus the "Next DIFAT Sector Location" field. + const fatNumber = header.majorVersion == 3 ? 127 : 1023; + + const difat = Array.from(header.difat); + + // Next DIFAT Sector Location (4 bytes): This field specifies the next sector number in the DIFAT chain of sectors. The first DIFAT sector is specified in the Header. The last DIFAT sector MUST set this field to ENDOFCHAIN (0xFFFFFFFE). + let sector = header.firstDifatSectorLocation; + while (sector < 0xFFFFFFFE) { + let offset = sectorOffset(sector, header.sectorSize); + + for (let i = 0; i < fatNumber; i++) { + if (view.getUint32(offset, true) == 0xFFFFFFFF) { + offset += (fatNumber - i) * 4; + break; + } + + difat.push(view.getUint32(offset, true)); + offset += 4; + } + + sector = view.getUint32(offset, true); + } + + return difat; +} \ No newline at end of file diff --git a/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/compound-file/directory/directory.ts b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/compound-file/directory/directory.ts new file mode 100644 index 0000000..19bf544 --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/compound-file/directory/directory.ts @@ -0,0 +1,157 @@ +import { ColorFlag } from "./enums/color-flag"; +import type { DirectoryEntry } from "./types/directory-entry"; +import { ObjectType } from "./enums/object-type"; +import type { Header } from "../header"; +import { sectorOffset } from "../util"; +import { TEXT_DECODER } from "../constants/text-decoder"; + +export class Directory { + constructor( + public readonly entries: DirectoryEntry[], + public readonly miniStreamLocations: number[] + ) {} + + static getDirectory(view: DataView, header: Header, fat: number[]): Directory { + const entrySize = 128; + const entriesCount = header.sectorSize / entrySize; + + const entries: DirectoryEntry[] = []; + + let sector = header.firstDirSectorLocation; + while (sector < 0xFFFFFFFE) { + let offset = sectorOffset(sector, header.sectorSize); + + for (let i = 0; i < entriesCount; i++) { + entries.push(this.directoryEntry(view, offset)); + + offset += entrySize; + } + + sector = fat[sector]; + } + + return new Directory( + entries, + this.getMiniStreamLocations(entries[0].startingSectorLocation, fat), + ); + } + + /** + * Traverses a Red-Black Tree to find the entry with the given name. + */ + get(name: string, root: number, deep: boolean): DirectoryEntry | null { + if (root < 0 || root >= this.entries.length) return null; + + const entry = this.entries[root]; + if (!entry) return null; + + const diff = this.compareName(name, entry.entryName); + + if (diff < 0) { + const left = this.get(name, entry.leftSiblingId, deep); + if (left) return left; + }else if (diff > 0) { + const right = this.get(name, entry.rightSiblingId, deep); + if (right) return right; + } else { + return entry; + } + + return deep ? this.get(name, entry.childId, deep) : null; + } + + private static getMiniStreamLocations(sector: number, fat: number[]): number[] { + const locations: number[] = []; + + while(sector < 0xFFFFFFFE) { + locations.push(sector); + sector = fat[sector]; + } + + return locations; + } + + private static directoryEntry(view: DataView, offset: number): DirectoryEntry { + const entryNameLength = view.getUint16(offset + 64, true); + const entryName = entryNameLength > 0 + ? TEXT_DECODER.decode(new DataView(view.buffer, offset, entryNameLength - 2)) + : ""; + offset += 66; + + const objectType = view.getUint8(offset) as ObjectType; + offset += 1; + + const colorFlag = view.getUint8(offset) as ColorFlag; + offset += 1; + + const leftSiblingId = view.getUint32(offset, true); + offset += 4; + + const rightSiblingId = view.getUint32(offset, true); + offset += 4; + + const childId = view.getUint32(offset, true); + offset += 4; + + const clsid = ""; + offset += 16; + + const stateBits = view.getUint32(offset, true); + offset += 4; + + const creationTime = view.getBigUint64(offset, true); + offset += 8; + + const modifiedTime = view.getBigUint64(offset, true); + offset += 8; + + const startingSectorLocation = view.getUint32(offset, true); + offset += 4; + + const streamSize = view.getBigUint64(offset, true); + offset += 8; + + return { + entryName, + entryNameLength, + objectType, + colorFlag, + leftSiblingId, + rightSiblingId, + childId, + clsid, + stateBits, + creationTime, + modifiedTime, + startingSectorLocation, + streamSize + } + } + + print() { + this.traverse((entry, depth) => console.log("\t".repeat(depth), entry.entryName)); + } + + traverse(action: (entry: DirectoryEntry, depth: number) => void) { + this.traverseFromRoot(0, 0, action); + } + + private traverseFromRoot(root: number, depth: number, action: (entry: DirectoryEntry, depth: number) => void) { + if (root < 0 || root >= this.entries.length) return; + + const entry = this.entries[root]; + if (!entry) return; + + this.traverseFromRoot(entry.leftSiblingId, depth, action); + this.traverseFromRoot(entry.rightSiblingId, depth, action); + + action(entry, depth); + this.traverseFromRoot(entry.childId, depth + 1, action); + } + + private compareName(name1: string, name2: string) { + if (name1.length < name2.length) return -1; + if (name1.length > name2.length) return 1; + return name1.toUpperCase().localeCompare(name2.toUpperCase()); + } +} \ No newline at end of file diff --git a/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/compound-file/directory/enums/color-flag.ts b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/compound-file/directory/enums/color-flag.ts new file mode 100644 index 0000000..10d81c5 --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/compound-file/directory/enums/color-flag.ts @@ -0,0 +1,4 @@ +export enum ColorFlag { + Red = 0x00, + Black = 0x01 +} diff --git a/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/compound-file/directory/enums/object-type.ts b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/compound-file/directory/enums/object-type.ts new file mode 100644 index 0000000..77cd5f2 --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/compound-file/directory/enums/object-type.ts @@ -0,0 +1,6 @@ +export enum ObjectType { + Unknown = 0x00, + Storage = 0x01, + Stream = 0x02, + RootStorage = 0x05 +} diff --git a/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/compound-file/directory/types/directory-entry.d.ts b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/compound-file/directory/types/directory-entry.d.ts new file mode 100644 index 0000000..ac79eec --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/compound-file/directory/types/directory-entry.d.ts @@ -0,0 +1,132 @@ +export interface DirectoryEntry { + /** + * Directory Entry Name (64 bytes): This field MUST contain a Unicode string for the storage or + * stream name encoded in UTF-16. The name MUST be terminated with a UTF-16 terminating null + * character. Thus, storage and stream names are limited to 32 UTF-16 code points, including the + * terminating null character. When locating an object in the compound file except for the root + * storage, the directory entry name is compared by using a special case-insensitive uppercase + * mapping, described in Red-Black Tree. The following characters are illegal and MUST NOT be part + * of the name: '/', '\', ':', '!'. + */ + entryName: string, + + /** + * Directory Entry Name Length (2 bytes): This field MUST match the length of the Directory Entry + * Name Unicode string in bytes. The length MUST be a multiple of 2 and include the terminating null + * character in the count. This length MUST NOT exceed 64, the maximum size of the Directory Entry + * Name field. + */ + entryNameLength: number, + + /** + * Object Type (1 byte): This field MUST be 0x00, 0x01, 0x02, or 0x05, depending on the actual type of object. All other values are not valid. + */ + objectType: ObjectType, + + /** + * Color Flag (1 byte): This field MUST be 0x00 (red) or 0x01 (black). All other values are not valid + */ + colorFlag: ColorFlag, + + /** + * Left Sibling ID (4 bytes): This field contains the stream ID of the left sibling. If there is no left sibling, the field MUST be set to NOSTREAM (0xFFFFFFFF). + * --------------------------------------------------------------------------------- + * Value | Meaning + * -------------------------------|------------------------------------------------- + * REGSID 0x00000000 — 0xFFFFFFF9 | Regular stream ID to identify the directory entry. + * MAXREGSID 0xFFFFFFFA | Maximum regular stream ID. + * NOSTREAM 0xFFFFFFFF | If there is no left sibling. + */ + leftSiblingId: number, + + /** + * Right Sibling ID (4 bytes): This field contains the stream ID of the right sibling. If there is no right sibling, the field MUST be set to NOSTREAM (0xFFFFFFFF). + * --------------------------------------------------------------------------------- + * Value | Meaning + * -------------------------------|------------------------------------------------- + * REGSID 0x00000000 — 0xFFFFFFF9 | Regular stream ID to identify the directory entry. + * MAXREGSID 0xFFFFFFFA | Maximum regular stream ID. + * NOSTREAM 0xFFFFFFFF | If there is no right sibling. + */ + rightSiblingId: number, + + /** + * Child ID (4 bytes): This field contains the stream ID of a child object. If there is no child object, including all entries for stream objects, the field MUST be set to NOSTREAM (0xFFFFFFFF). + * --------------------------------------------------------------------------------- + * Value | Meaning + * -------------------------------|------------------------------------------------- + * REGSID 0x00000000 — 0xFFFFFFF9 | Regular stream ID to identify the directory entry. + * MAXREGSID 0xFFFFFFFA | Maximum regular stream ID. + * NOSTREAM 0xFFFFFFFF | If there is no child object. + */ + childId: number, + + /** + * CLSID (16 bytes): This field contains an object class GUID, if this entry is for a storage object or + * root storage object. For a stream object, this field MUST be set to all zeroes. A value containing all + * zeroes in a storage or root storage directory entry is valid, and indicates that no object class is + * associated with the storage. If an implementation of the file format enables applications to create + * storage objects without explicitly setting an object class GUID, it MUST write all zeroes by default. + * If this value is not all zeroes, the object class GUID can be used as a parameter to start + * applications. + * + * ----------------------------------------------------------------------------------- + * Value | Meaning + * -----------------------------------|----------------------------------------------- + * 0x00000000000000000000000000000000 | No object class is associated with the storage. + */ + clsid: string, + + /** + * State Bits (4 bytes): This field contains the user-defined flags if this entry is for a storage object or + * root storage object. For a stream object, this field SHOULD be set to all zeroes because many + * implementations provide no way for applications to retrieve state bits from a stream object. If an + * implementation of the file format enables applications to create storage objects without explicitly + * setting state bits, it MUST write all zeroes by default. + * -------------------------------------------------------------------------------- + * Value | Meaning + * ------------|------------------------------------------------------------------- + * 0x00000000 | Default value when no state bits are explicitly set on the object. + */ + stateBits: number, + + /** + * Creation Time (8 bytes): This field contains the creation time for a storage object, or all zeroes to + * indicate that the creation time of the storage object was not recorded. The Windows FILETIME + * structure is used to represent this field in UTC. For a stream object, this field MUST be all zeroes. + * For a root storage object, this field MUST be all zeroes, and the creation time is retrieved or set on + * the compound file itself. + * ------------------------------------------------------------------- + * Value | Meaning + * --------------------|---------------------------------------------- + * 0x0000000000000000 | No creation time was recorded for the object. + */ + creationTime: bigint, + + /** + * Modified Time (8 bytes): This field contains the modification time for a storage object, or all + * zeroes to indicate that the modified time of the storage object was not recorded. The Windows + * FILETIME structure is used to represent this field in UTC. For a stream object, this field MUST be + * all zeroes. For a root storage object, this field MAY<2> be set to all zeroes, and the modified time + * is retrieved or set on the compound file itself. + * ------------------------------------------------------------------- + * Value | Meaning + * --------------------|---------------------------------------------- + * 0x0000000000000000 | No modified time was recorded for the object. + */ + modifiedTime: bigint, + + /** + * Starting Sector Location (4 bytes): This field contains the first sector location if this is a stream + * object. For a root storage object, this field MUST contain the first sector of the mini stream, if the + * mini stream exists. For a storage object, this field MUST be set to all zeroes. + */ + startingSectorLocation: number, + + /** + * Stream Size (8 bytes): This 64-bit integer field contains the size of the user-defined data if this is + * a stream object. For a root storage object, this field contains the size of the mini stream. For a + * storage object, this field MUST be set to all zeroes. + */ + streamSize: bigint +} \ No newline at end of file diff --git a/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/compound-file/fat.ts b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/compound-file/fat.ts new file mode 100644 index 0000000..c2f719d --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/compound-file/fat.ts @@ -0,0 +1,24 @@ +import type { Header } from "./header"; +import { fatSectorSize, sectorOffset } from "./util"; + +/** + * The FAT is an array of sector numbers that represent the allocation of space within the file, grouped + * into FAT sectors. Each stream is represented in the FAT by a sector chain, in much the same fashion + * as a FAT file system. + */ +export function getFat(view: DataView, header: Header, difat: number[]): number[] { + const sectorSize = fatSectorSize(header); + + const fat: number[] = []; + for (let i = 0; i < difat.length; i++) { + let offset = sectorOffset(difat[i], header.sectorSize); + + for (let j = 0; j < sectorSize; j++) { + const nextSector = view.getUint32(offset, true); + fat.push(nextSector); + offset += 4; + } + } + + return fat; +} \ No newline at end of file diff --git a/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/compound-file/header.ts b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/compound-file/header.ts new file mode 100644 index 0000000..c4f621d --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/compound-file/header.ts @@ -0,0 +1,125 @@ +export interface Header { + // Header Signature (8 bytes): Identification signature for the compound file structure, and MUST be set to the value 0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1. + signature: number[], + + // Minor Version (2 bytes): Version number for nonbreaking changes. This field SHOULD be set to 0x003E if the major version field is either 0x0003 or 0x0004. + minorVersion: number, + + // Major Version (2 bytes): Version number for breaking changes. This field MUST be set to either 0x0003 (version 3) or 0x0004 (version 4). + majorVersion: number, + + // Byte Order (2 bytes): This field MUST be set to 0xFFFE. This field is a byte order mark for all integer fields, specifying little-endian byte order. + byteOrder: number, + + // Sector Shift (2 bytes): This field MUST be set to 0x0009, or 0x000c, depending on the Major Version field. + // Sector Size = 2 ^ SectorShift. e.g. 2 ^ 9 = 512, 2 ^ 12 = 4096 + sectorSize: number, + + // Mini Sector Shift (2 bytes): This field MUST be set to 0x0006. This field specifies the sector size of the Mini Stream as a power of 2. The sector size of the Mini Stream MUST be 64 bytes. + // Mini Sector Size = 2 ^ Mini Sector Shift. e.g. 2 ^ 6 = 64 + miniSectorSize: number, + + // Number of Directory Sectors (4 bytes): This integer field contains the count of the number of directory sectors in the compound file. + numberOfDirectorySectors: number, + + // Number of FAT Sectors (4 bytes): This integer field contains the count of the number of FAT sectors in the compound file. + numberOfFatSectors: number, + + // First Directory Sector Location (4 bytes): This integer field contains the starting sector number for the directory stream. + firstDirSectorLocation: number, + + // Transaction Signature Number (4 bytes): This integer field MAY contain a sequence number that is incremented every time the compound file is saved by an implementation that supports file transactions. This is the field that MUST be set to all zeroes if file transactions are not implemented. + transactionSignatureNumber: number, + + // Mini Stream Cutoff Size (4 bytes): This integer field MUST be set to 0x00001000. This field specifies the maximum size of a user-defined data stream that is allocated from the mini FAT and mini stream, and that cutoff is 4,096 bytes. Any user-defined data stream that is greater than or equal to this cutoff size must be allocated as normal sectors from the FAT. + miniStreamCutOffSize: number, + + // First Mini FAT Sector Location (4 bytes): This integer field contains the starting sector number for the mini FAT. + firstMiniFatSectorLocation: number, + + // Number of Mini FAT Sectors (4 bytes): This integer field contains the count of the number of mini FAT sectors in the compound file. + numberOfMiniFatSectors: number, + + // First DIFAT Sector Location (4 bytes): This integer field contains the starting sector number for the DIFAT. + firstDifatSectorLocation: number, + + // Number of DIFAT Sectors (4 bytes): This integer field contains the count of the number of DIFAT sectors in the compound file. + numberOfDifatSectors: number, + + // DIFAT (436 bytes): This array of 32-bit integer fields contains the first 109 FAT sector locations of the compound file. For version 4 compound files, the header size (512 bytes) is less than the sector size (4,096 bytes), so the remaining part of the header (3,584 bytes) MUST be filled with all zeroes. + // The DIFAT array is used to represent storage of the FAT sectors. + difat: number[] +} + +export function getHeader(view: DataView): Header { + const signature = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]; + for (var offset = 0; offset < 8; offset++) { + if (view.getUint8(offset) != signature[offset]) { + throw new Error("Signature mismatch!"); + } + } + + // Header CLSID (16 bytes): Reserved and unused class ID that MUST be set to all zeroes + offset += 16; + + const minorVersion = view.getUint16(offset, true); + offset += 2; + + const majorVersion = view.getUint16(offset, true); + offset += 2; + + const byteOrder = view.getUint16(offset, true); + offset += 2; + + const sectorSize = Math.pow(2, view.getUint16(offset, true)); + offset += 2; + + const miniSectorSize = Math.pow(2, view.getUint16(offset, true)); + offset += 2; + + // Reserved (6 bytes): This field MUST be set to all zeroes. + offset += 6; + + const numberOfDirectorySectors = view.getUint32(offset, true) + offset += 4; + + const numberOfFatSectors = view.getUint32(offset, true) + offset += 4; + + const firstDirSectorLocation = view.getUint32(offset, true); + offset += 4; + + const transactionSignatureNumber = view.getUint32(offset, true); + offset += 4; + + const miniStreamCutOffSize = view.getUint32(offset, true); + offset += 4; + + const firstMiniFatSectorLocation = view.getUint32(offset, true); + offset += 4; + + const numberOfMiniFatSectors = view.getUint32(offset, true) + offset += 4; + + const firstDifatSectorLocation = view.getUint32(offset, true) + offset += 4; + + const numberOfDifatSectors = view.getUint32(offset, true); + offset += 4; + + const difat: number[] = []; + for (let i = 0; i < 436; i += 4) { + const fat = view.getUint32(offset, true); + if (fat == 0xFFFFFFFF) break; + + difat.push(fat); + offset += 4; + } + + return { + signature, byteOrder, sectorSize, miniSectorSize, minorVersion, majorVersion, numberOfDirectorySectors, + numberOfFatSectors, firstDirSectorLocation, transactionSignatureNumber, + miniStreamCutOffSize, firstMiniFatSectorLocation, numberOfMiniFatSectors, + firstDifatSectorLocation, numberOfDifatSectors, difat + } +} diff --git a/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/compound-file/mini-fat.ts b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/compound-file/mini-fat.ts new file mode 100644 index 0000000..f67ca5f --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/compound-file/mini-fat.ts @@ -0,0 +1,26 @@ +import type { Header } from "./header"; +import { fatSectorSize, sectorOffset } from "./util"; + +/** + * The mini FAT is used to allocate space in the mini stream. The mini stream is divided into smaller, + * equal-length sectors, and the sector size that is used for the mini stream is specified from the + * Compound File Header (64 bytes). + */ +export function getMiniFat(view: DataView, header: Header, fat: number[]): number[] { + const sectorSize = fatSectorSize(header); + + const miniFat = []; + + let sector = header.firstMiniFatSectorLocation; + while (sector < 0xFFFFFFFE) { + let offset = sectorOffset(sector, header.sectorSize); + for (let i = 0; i < sectorSize; i++) { + miniFat.push(view.getUint32(offset, true)); + offset += 4; + } + + sector = fat[sector]; + } + + return miniFat; +} \ No newline at end of file diff --git a/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/compound-file/util.ts b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/compound-file/util.ts new file mode 100644 index 0000000..c9e63a1 --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/compound-file/util.ts @@ -0,0 +1,31 @@ +import type { Header } from "./header"; + +/** + * Based on a non-stream sector number returns the byte offset in the Buffer. + */ +export function sectorOffset(sector: number, sectorSize: number): number { + return (sector + 1) * sectorSize; +} + +/** + * Based on a stream sector number returns the byte offset in the Buffer. + */ +export function streamSectorOffset(sector: number, header: Header, streamSize: bigint, miniStreamLocations: number[]): number { + let offset = sectorOffset(sector, header.sectorSize); + + // Calculates the offset in ministream if streamSize is smaller than miniStreamCutOffSize + if (streamSize < header.miniStreamCutOffSize) { + offset = sector * header.miniSectorSize; + const miniStreamSector = Math.floor(offset / header.sectorSize); + const miniStreamOffset = offset % header.sectorSize; + offset = sectorOffset(miniStreamLocations[miniStreamSector], header.sectorSize) + miniStreamOffset ; + } + + return offset; +} + +export function fatSectorSize(header: Header) { + // If Header Major Version is 3, there MUST be 128 fields specified to fill a 512-byte sector. + // If Header Major Version is 4, there MUST be 1,024 fields specified to fill a 4,096-byte sector. + return header.majorVersion == 3 ? 128 : 1024; +} diff --git a/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/msg-parser.ts b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/msg-parser.ts new file mode 100644 index 0000000..a2e52e9 --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/msg-parser.ts @@ -0,0 +1,110 @@ +import { CompoundFile } from "./compound-file/compound-file"; +import { TEXT_DECODER } from "./compound-file/constants/text-decoder"; +import type { DirectoryEntry } from "./compound-file/directory/types/directory-entry"; +import { ATTACH_PROPERTIES, PropertySource, RECIP_PROPERTIES, ROOT_PROPERTIES, type Property } from "./streams/property/properties"; +import { getPropertyStreamEntry } from "./streams/property/property-stream"; +import { PtypBinary, PtypObject, PtypString, PtypTime, type PropertyType } from "./streams/property/property-types"; +import type { PropertyStreamEntry } from "./streams/property/types/property-stream-entry"; +import type { Attachment, Message, MessageContent, Recipient } from "./types/message"; + +export function parse(view: DataView): Message { + const file = CompoundFile.create(view); + const dir = file.directory.entries[0]; + + return parseDir(file, dir); +} + +export function parseDir(file: CompoundFile, dir: DirectoryEntry): Message { + const pStreamEntry = getPropertyStreamEntry(file, dir)!; + + return { + file: file, + content: getContent(file, dir, pStreamEntry), + attachments: getAttachments(file, dir), + recipients: getRecipients(file, dir), + }; +} + +function getContent(file: CompoundFile, dir: DirectoryEntry, pStreamEntry: PropertyStreamEntry): MessageContent { + return getValue(file, ROOT_PROPERTIES, dir, pStreamEntry); +} + +function getRecipients(file: CompoundFile, dir: DirectoryEntry): Recipient[] { + return getValues(file, dir, RECIP_PROPERTIES, "recip"); +} + +function getAttachments(file: CompoundFile, dir: DirectoryEntry): Attachment[] { + return getValues(file, dir, ATTACH_PROPERTIES, "attach"); +} + +function getValues(file: CompoundFile, dir: DirectoryEntry, properties: Property[], prefix: string): T[] { + const list: T[] = []; + + for (let i = 0; i < 2048; i++) { + const directory = file.directory.get(`__${prefix}_version1.0_#${i.toString(16).padStart(8, "0")}`, dir.childId, false); + if (!directory) break; + + const pStreamEntry = getPropertyStreamEntry(file, directory)!; + list.push(getValue(file, properties, directory, pStreamEntry)); + } + + return list; +} + +function getValue(file: CompoundFile, properties: Property[], dir: DirectoryEntry, entry: PropertyStreamEntry): T { + return properties.reduce((acc, p) => { + if (p.source == PropertySource.Stream) { + const streamName = `__substg1.0_${p.id.padStart(4, "0")}${p.type.id.toString(16).padStart(4, "0")}`; + const entry = file.directory.get(streamName, dir.childId, false); + if (!entry) return acc; + acc[p.name as keyof T] = getValueFromStream(file, entry, p.type) as T[keyof T]; + } else { + const value = getValueFromProperty(entry, p); + if (!value) return acc; + acc[p.name as keyof T] = value as T[keyof T]; + } + + return acc; + }, {} as T); +} + +function getValueFromProperty(entry: PropertyStreamEntry, property: Property) { + const value = entry.data.get(property.id.toLowerCase())?.valueOrSize; + if (!value) return ""; + + switch (property.type) { + case PtypTime: { + // Subtracting the number of seconds between January 1, 1601 and January 1, 1970. + return new Date(Number(value as bigint / 10000n) - 1.16444736e13); + } + default: return value; + } +} + +function getValueFromStream(file: CompoundFile, entry: DirectoryEntry, type: PropertyType) { + switch (type) { + case PtypString: { + let value = ""; + file.readStream(entry, (offset, bytes) => { + value += TEXT_DECODER.decode(new DataView(file.view.buffer, offset, bytes)); + }); + + return value; + }; + case PtypBinary: { + const chunks = new Uint8Array(Number(entry.streamSize)); + let pos = 0; + file.readStream(entry, (offset, bytes) => { + const chunk = file.view.buffer.slice(offset, offset + bytes); + chunks.set(new Uint8Array(chunk), pos); + pos += bytes; + }); + + return new DataView(chunks.buffer); + }; + case PtypObject: { + return entry; + } + default: return null; + }; +} diff --git a/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/package.json b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/package.json new file mode 100644 index 0000000..d2fff6c --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/package.json @@ -0,0 +1,8 @@ +{ + "name": "@molotochok/msg-viewer", + "version": "1.0.3", + "author": "Markiian Karpa", + "main": "msg-parser.ts", + "description": "This tool allows you to read Outlook .msg file.", + "license": "MIT" +} \ No newline at end of file diff --git a/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/rtf/decompressor/crc.ts b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/rtf/decompressor/crc.ts new file mode 100644 index 0000000..e8e82b9 --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/rtf/decompressor/crc.ts @@ -0,0 +1,19 @@ +const TABLE = [0x00000000, 0x77073096, 0xee0e612c, 0x990951ba, 0x076dc419, 0x706af48f, 0xe963a535, 0x9e6495a3, 0x0edb8832, 0x79dcb8a4, 0xe0d5e91e, 0x97d2d988, 0x09b64c2b, 0x7eb17cbd, 0xe7b82d07, 0x90bf1d91, 0x1db71064, 0x6ab020f2, 0xf3b97148, 0x84be41de, 0x1adad47d, 0x6ddde4eb, 0xf4d4b551, 0x83d385c7, 0x136c9856, 0x646ba8c0, 0xfd62f97a, 0x8a65c9ec, 0x14015c4f, 0x63066cd9, 0xfa0f3d63, 0x8d080df5, 0x3b6e20c8, 0x4c69105e, 0xd56041e4, 0xa2677172, 0x3c03e4d1, 0x4b04d447, 0xd20d85fd, 0xa50ab56b, 0x35b5a8fa, 0x42b2986c, 0xdbbbc9d6, 0xacbcf940, 0x32d86ce3, 0x45df5c75, 0xdcd60dcf, 0xabd13d59, 0x26d930ac, 0x51de003a, 0xc8d75180, 0xbfd06116, 0x21b4f4b5, 0x56b3c423, 0xcfba9599, 0xb8bda50f, 0x2802b89e, 0x5f058808, 0xc60cd9b2, 0xb10be924, 0x2f6f7c87, 0x58684c11, 0xc1611dab, 0xb6662d3d, 0x76dc4190, 0x01db7106, 0x98d220bc, 0xefd5102a, 0x71b18589, 0x06b6b51f, 0x9fbfe4a5, 0xe8b8d433, 0x7807c9a2, 0x0f00f934, 0x9609a88e, 0xe10e9818, 0x7f6a0dbb, 0x086d3d2d, 0x91646c97, 0xe6635c01, 0x6b6b51f4, 0x1c6c6162, 0x856530d8, 0xf262004e, 0x6c0695ed, 0x1b01a57b, 0x8208f4c1, 0xf50fc457, 0x65b0d9c6, 0x12b7e950, 0x8bbeb8ea, 0xfcb9887c, 0x62dd1ddf, 0x15da2d49, 0x8cd37cf3, 0xfbd44c65, 0x4db26158, 0x3ab551ce, 0xa3bc0074, 0xd4bb30e2, 0x4adfa541, 0x3dd895d7, 0xa4d1c46d, 0xd3d6f4fb, 0x4369e96a, 0x346ed9fc, 0xad678846, 0xda60b8d0, 0x44042d73, 0x33031de5, 0xaa0a4c5f, 0xdd0d7cc9, 0x5005713c, 0x270241aa, 0xbe0b1010, 0xc90c2086, 0x5768b525, 0x206f85b3, 0xb966d409, 0xce61e49f, 0x5edef90e, 0x29d9c998, 0xb0d09822, 0xc7d7a8b4, 0x59b33d17, 0x2eb40d81, 0xb7bd5c3b, 0xc0ba6cad, 0xedb88320, 0x9abfb3b6, 0x03b6e20c, 0x74b1d29a, 0xead54739, 0x9dd277af, 0x04db2615, 0x73dc1683, 0xe3630b12, 0x94643b84, 0x0d6d6a3e, 0x7a6a5aa8, 0xe40ecf0b, 0x9309ff9d, 0x0a00ae27, 0x7d079eb1, 0xf00f9344, 0x8708a3d2, 0x1e01f268, 0x6906c2fe, 0xf762575d, 0x806567cb, 0x196c3671, 0x6e6b06e7, 0xfed41b76, 0x89d32be0, 0x10da7a5a, 0x67dd4acc, 0xf9b9df6f, 0x8ebeeff9, 0x17b7be43, 0x60b08ed5, 0xd6d6a3e8, 0xa1d1937e, 0x38d8c2c4, 0x4fdff252, 0xd1bb67f1, 0xa6bc5767, 0x3fb506dd, 0x48b2364b, 0xd80d2bda, 0xaf0a1b4c, 0x36034af6, 0x41047a60, 0xdf60efc3, 0xa867df55, 0x316e8eef, 0x4669be79, 0xcb61b38c, 0xbc66831a, 0x256fd2a0, 0x5268e236, 0xcc0c7795, 0xbb0b4703, 0x220216b9, 0x5505262f, 0xc5ba3bbe, 0xb2bd0b28, 0x2bb45a92, 0x5cb36a04, 0xc2d7ffa7, 0xb5d0cf31, 0x2cd99e8b, 0x5bdeae1d, 0x9b64c2b0, 0xec63f226, 0x756aa39c, 0x026d930a, 0x9c0906a9, 0xeb0e363f, 0x72076785, 0x05005713, 0x95bf4a82, 0xe2b87a14, 0x7bb12bae, 0x0cb61b38, 0x92d28e9b, 0xe5d5be0d, 0x7cdcefb7, 0x0bdbdf21, 0x86d3d2d4, 0xf1d4e242, 0x68ddb3f8, 0x1fda836e, 0x81be16cd, 0xf6b9265b, 0x6fb077e1, 0x18b74777, 0x88085ae6, 0xff0f6a70, 0x66063bca, 0x11010b5c, 0x8f659eff, 0xf862ae69, 0x616bffd3, 0x166ccf45, 0xa00ae278, 0xd70dd2ee, 0x4e048354, 0x3903b3c2, 0xa7672661, 0xd06016f7, 0x4969474d, 0x3e6e77db, 0xaed16a4a, 0xd9d65adc, 0x40df0b66, 0x37d83bf0, 0xa9bcae53, 0xdebb9ec5, 0x47b2cf7f, 0x30b5ffe9, 0xbdbdf21c, 0xcabac28a, 0x53b39330, 0x24b4a3a6, 0xbad03605, 0xcdd70693, 0x54de5729, 0x23d967bf, 0xb3667a2e, 0xc4614ab8, 0x5d681b02, 0x2a6f2b94, 0xb40bbe37, 0xc30c8ea1, 0x5a05df1b, 0x2d02ef8d]; + +/** + * Cyclic redundancy check which is used to check if the RTF file is corrupted. + * It must be equal to the value taken from the header. + */ +export function crc(view: DataView, headerSize: number) { + let tablePosition = 0, intermidiateValue = 0, crcValue = 0; + + for (let i = headerSize; i < view.byteLength; i++) { + tablePosition = (crcValue ^ view.getUint8(i)) & 0xFF; + intermidiateValue = crcValue >>> 8; + // >>> 0 is needed to avoid a negative number. + // In this way JS will treat the number as unsigned. + crcValue = (TABLE[tablePosition] ^ intermidiateValue) >>> 0; + } + + return crcValue; +} diff --git a/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/rtf/decompressor/dictionary.ts b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/rtf/decompressor/dictionary.ts new file mode 100644 index 0000000..755ca21 --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/rtf/decompressor/dictionary.ts @@ -0,0 +1,14 @@ +const STR = "{\\rtf1\\ansi\\mac\\deff0\\deftab720{\\fonttbl;}{\\f0\\fnil \\froman \\fswiss \\fmodern \\fscript \\fdecor MS Sans SerifSymbolArialTimes New RomanCourier{\\colortbl\\red0\\green0\\blue0\r\n\\par \\pard\\plain\\f0\\fs20\\b\\i\\u\\tab\\tx"; + +let dict: Uint8Array | null = null; + +export function getDictionary(): Uint8Array { + if (!dict) { + dict = new Uint8Array(4096); + for (let i = 0; i < STR.length; i++) { + dict[i] = STR.charCodeAt(i); + } + } + + return dict; +} \ No newline at end of file diff --git a/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/rtf/decompressor/header.ts b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/rtf/decompressor/header.ts new file mode 100644 index 0000000..b7f32d5 --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/rtf/decompressor/header.ts @@ -0,0 +1,49 @@ +export function getHeader(view: DataView): Header { + let offset = 0; + + const compSize = view.getUint32(offset, true); + offset += 4; + + const rawSize = view.getUint32(offset, true); + offset += 4; + + const compType = view.getUint32(offset, true) === 0x75465A4C ? CompType.Compressed : CompType.Uncompressed; + offset += 4; + + const crc = view.getUint32(offset, true); + offset += 4; + + return { compSize, rawSize, compType, crc, headerSize: offset }; +} + +export interface Header { + /** + * Writers MUST set the COMPSIZE field to the length of the compressed data + * (the CONTENTS field) in bytes plus 12 (the count of the remaining bytes from the header). + */ + compSize: number; + + /** + * The size in bytes of the uncompressed content. + */ + rawSize: number; + + /** + * The type of compression. + */ + compType: CompType; + + /** + * Cyclic redundancy check. Used to verify that the file is not broken. + * If the COMPTYPE field is set to COMPRESSED, then the CRC field is computed from the CONTENTS field. + * If the COMPTYPE field is set to UNCOMPRESSED, then the CRC field MUST be set to %x00.00.00.00. + */ + crc: number; + + /** + * The size in bytes of the header. + */ + headerSize: number; +} + +export const enum CompType { Compressed, Uncompressed } \ No newline at end of file diff --git a/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/rtf/rtf-decompressor.ts b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/rtf/rtf-decompressor.ts new file mode 100644 index 0000000..f27a426 --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/rtf/rtf-decompressor.ts @@ -0,0 +1,69 @@ +import { crc } from "./decompressor/crc"; +import { getDictionary } from "./decompressor/dictionary"; +import { CompType, getHeader, type Header } from "./decompressor/header"; + +export function decompressRTF(view: DataView): string { + const header = getHeader(view); + return getContent(view, header); +} + +function getContent(view: DataView, header: Header): string { + if (header.compType == CompType.Uncompressed) { + const buffer = new Uint8Array(view.buffer.slice(header.headerSize, header.headerSize + header.rawSize)); + return String.fromCharCode(...buffer); + } + + const currentCRC = crc(view, header.headerSize); + if (currentCRC !== header.crc) { + throw new Error(`CRC mismatch! Expected ${header.crc}, got ${currentCRC}.`); + } + + const output = []; + const dictionary = getDictionary(); + + let offset = header.headerSize; + let writeOffset = 207; + let readOffset = 0; + let canRun = true; + while (offset <= header.compSize + 4 && canRun) { + const control = view.getUint8(offset); + offset += 1; + + for (let i = 0; i < 8; i++) { + const bit = (control >>> i) & 1; + + if (bit == 0) { + const literal = view.getUint8(offset); + offset += 1; + + dictionary[writeOffset] = literal; + writeOffset = (writeOffset + 1) % dictionary.length; + + output.push(literal); + } else { + const ref = view.getUint16(offset); + const refOffset = ref >>> 4; + offset += 2; + + if (refOffset == writeOffset) { + canRun = false; + break; + } + + readOffset = refOffset; + const refLength = 2 + (ref & 0x0f); + for (let j = 0; j < refLength; j++) { + const byte = dictionary[readOffset]; + readOffset = (readOffset + 1) % dictionary.length; + + dictionary[writeOffset] = byte; + writeOffset = (writeOffset + 1) % dictionary.length; + + output.push(byte); + } + } + } + } + + return String.fromCharCode(...output); +} diff --git a/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/streams/constants/folders.ts b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/streams/constants/folders.ts new file mode 100644 index 0000000..3f815b3 --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/streams/constants/folders.ts @@ -0,0 +1 @@ +export const NAME_ID_FOLDER_NAME = "__nameid_version1.0"; \ No newline at end of file diff --git a/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/streams/entry/entry-stream.ts b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/streams/entry/entry-stream.ts new file mode 100644 index 0000000..08e0fa1 --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/streams/entry/entry-stream.ts @@ -0,0 +1,45 @@ +import type { CompoundFile } from "../../compound-file/compound-file"; +import { NAME_ID_FOLDER_NAME } from "../constants/folders"; +import type { EntryStreamData } from "./types/entry-stream-entry"; + +const DATA_SIZE = 8; +const STREAM_NAME = "__substg1.0_00030102"; + +export function getEntryStreamData(file: CompoundFile): EntryStreamData[] | null { + const folder = file.directory.get(NAME_ID_FOLDER_NAME, file.directory.entries[0].childId, false); + if (!folder) return null; + + const entry = file.directory.get(STREAM_NAME, folder.childId, false); + if (!entry) return null; + + const list: EntryStreamData[] = []; + file.readStream(entry, + (offset, bytes) => { + const data = getData(file.view, offset); + list.push(data); + }, + DATA_SIZE, + ); + + return list; +} + +function getData(view: DataView, offset: number): EntryStreamData { + const nameIdOrStringOffset = view.getUint32(offset, true); + offset += 4; + + const guidWithKind = view.getUint16(offset, true); + const propertyKind = guidWithKind & 1; + const guidIndex = guidWithKind >> 1; + offset += 2; + + const propertyIndex = view.getUint16(offset, true) + offset += 2; + + return { + nameIdOrStringOffset, + guidIndex, + propertyIndex, + propertyKind + } +} \ No newline at end of file diff --git a/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/streams/entry/types/entry-stream-entry.ts b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/streams/entry/types/entry-stream-entry.ts new file mode 100644 index 0000000..0bf51b4 --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/streams/entry/types/entry-stream-entry.ts @@ -0,0 +1,26 @@ +export interface EntryStreamData { + /** + * Name Identifier/String Offset (4 bytes): If this property is a numerical named property (as specified by the Property Kind subfield of the Index and Kind Information field), this value is the LID part of the PropertyName structure, as specified in [MS-OXCDATA] section 2.6.1. If this property is a string named property, this value is the offset in bytes into the strings stream where the value of the Name field of the PropertyName structure is located. + */ + nameIdOrStringOffset: number, + + /** + * Property Index (2 bytes): Sequentially increasing, zero-based index. This MUST be 0 for the first named property, 1 for the second, and so on. + */ + propertyIndex: number, + + /** + * GUID Index (15 bits): Index into the GUID stream. The possible values are shown in the following table. + */ + guidIndex: number, + + /** + * Property Kind (1 bit): Bit indicating the type of the property; zero (0) if numerical named property and 1 if string named property. + */ + propertyKind: PropertyKind +} + +export enum PropertyKind { + NUMERICAL = 0, + STRING = 1 +} \ No newline at end of file diff --git a/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/streams/property/properties.ts b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/streams/property/properties.ts new file mode 100644 index 0000000..8b920c9 --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/streams/property/properties.ts @@ -0,0 +1,41 @@ +import { PtypBinary, PtypObject, PtypString, PtypTime, type PropertyType } from "./property-types"; + +export const enum PropertySource { + Stream, // Property can be found in a dedicated stream + Property // Property is located in property stream +} + +export const ROOT_PROPERTIES: Property[] = [ + { id: "0E06", name:"date", type: PtypTime, source: PropertySource.Property }, + { id: "0037", name:"subject", type: PtypString, source: PropertySource.Stream }, + { id: "0c1a", name:"senderName", type: PtypString, source: PropertySource.Stream }, + { id: "5d02", name:"senderEmail", type: PtypString, source: PropertySource.Stream }, + { id: "1000", name:"body", type: PtypString, source: PropertySource.Stream }, + { id: "1013", name:"bodyHTML", type: PtypString, source: PropertySource.Stream }, + { id: "1009", name:"bodyRTF", type: PtypBinary, source: PropertySource.Stream }, + { id: "007d", name:"headers", type: PtypString, source: PropertySource.Stream }, + { id: "0E04", name:"toRecipients", type: PtypString, source: PropertySource.Stream }, + { id: "0E03", name:"ccRecipients", type: PtypString, source: PropertySource.Stream }, +]; + +export const ATTACH_PROPERTIES: Property[]= [ + { id: "3703", name:"extension", type: PtypString, source: PropertySource.Stream }, + { id: "3707", name:"fileName", type: PtypString, source: PropertySource.Stream }, + { id: "370e", name:"mimeType", type: PtypString, source: PropertySource.Stream }, + { id: "3A0C", name:"language", type: PtypString, source: PropertySource.Stream }, + { id: "3001", name:"displayName", type: PtypString, source: PropertySource.Stream }, + { id: "3701", name:"content", type: PtypBinary, source: PropertySource.Stream }, + { id: "3701", name:"embeddedMsgObj", type: PtypObject, source: PropertySource.Stream }, +]; + +export const RECIP_PROPERTIES: Property[] = [ + { id: "3001", name:"name", type: PtypString, source: PropertySource.Stream }, + { id: "39fe", name:"email", type: PtypString, source: PropertySource.Stream }, +]; + +export interface Property { + id: string, + name: string, + type: PropertyType, + source: PropertySource, +} diff --git a/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/streams/property/property-stream.ts b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/streams/property/property-stream.ts new file mode 100644 index 0000000..9a57367 --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/streams/property/property-stream.ts @@ -0,0 +1,88 @@ +import type { CompoundFile } from "../../compound-file/compound-file"; +import type { DirectoryEntry } from "../../compound-file/directory/types/directory-entry"; +import { PROPERTY_TYPES } from "./property-types"; +import type { PropertyHeader } from "./types/property-header"; +import type { PropertyData } from "./types/property-data"; +import type { PropertyStreamEntry } from "./types/property-stream-entry"; + +const STREAM_NAME = "__properties_version1.0"; +const DATA_SIZE = 16; + +export function getPropertyStreamEntry(file: CompoundFile, folder: DirectoryEntry): PropertyStreamEntry | null { + const entry = file.directory.get(STREAM_NAME, folder.childId, false); + if (!entry) return null; + + let header; + const data = new Map(); + file.readStream(entry, + (offset) => { + const property = getProperty(file.view, offset); + data.set(property.propertyId.toString(16).toLowerCase().padStart(4, "0"), property); + }, + DATA_SIZE, + (offset) => { + header = getHeader(file.view, offset, folder.entryName); + return header.size; + } + ); + + return { header: header!, data: data }; +} + +function getProperty(view: DataView, offset: number): PropertyData { + const propertyTag = view.getUint32(offset, true); + const propertyType = PROPERTY_TYPES[0xFFFF & propertyTag]; + const propertyId = propertyTag >>> 16; + + offset += 4; + + const flags = view.getUint32(offset, true); + offset += 4; + + const valueOrSize = (!propertyType?.size || propertyType?.multi) + ? view.getUint32(offset, true) + : (propertyType.size == 1) + ? view.getUint8(offset) + : propertyType.size == 2 + ? view.getUint16(offset, true) + : propertyType.size == 4 + ? view.getUint32(offset, true) + : view.getBigUint64(offset, true); + + return { + propertyType, + propertyId, + flags, + valueOrSize + }; +} + +function getHeader(view: DataView, offset: number, folderName: string): PropertyHeader { + if (["__attach", "__recip"].some(v => folderName.startsWith(v))) return { size: 8 }; + + const initialOffset = offset; + + // Reserved (8 bytes): This field MUST be set to zero when writing a .msg file and MUST be ignored when reading a .msg file. + offset += 8; + + const nextRecipientId = view.getUint32(offset, true); + offset += 4; + + const nextAttachmentId = view.getUint32(offset, true); + offset += 4; + + const recipientCount = view.getUint32(offset, true); + offset += 4; + + const attachmentCount = view.getUint32(offset, true); + offset += 4; + + if (folderName.startsWith("Root")) { + // Reserved (8 bytes): This field MUST be set to 0 when writing a .msg file and MUST be ignored when reading a .msg file. + offset += 8; + } + + + return { size: offset - initialOffset, nextRecipientId, nextAttachmentId, recipientCount, attachmentCount }; +} + diff --git a/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/streams/property/property-types.ts b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/streams/property/property-types.ts new file mode 100644 index 0000000..dbe5141 --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/streams/property/property-types.ts @@ -0,0 +1,83 @@ +/**** Fixed length ****/ +export const PtypInteger16: PropertyType = { id: 0x0002, name: "PtypInteger16", size: 2, multi: false } +export const PtypInteger32: PropertyType = { id: 0x0003, name: "PtypInteger32", size: 4, multi: false } +export const PtypFloating32: PropertyType = { id: 0x0004, name: "PtypFloating32", size: 4, multi: false } +export const PtypFloating64: PropertyType = { id: 0x0005, name: "PtypFloating64", size: 8, multi: false } +export const PtypBoolean: PropertyType = { id: 0x000B, name: "PtypBoolean", size: 1, multi: false } +export const PtypCurrency: PropertyType = { id: 0x0006, name: "PtypCurrency", size: 8, multi: false } +export const PtypFloatingTime: PropertyType = { id: 0x0007, name: "PtypFloatingTime", size: 8, multi: false } +export const PtypTime: PropertyType = { id: 0x0040, name: "PtypTime", size: 8, multi: false } +export const PtypInteger64: PropertyType = { id: 0x0014, name: "PtypInteger64", size: 8, multi: false } +export const PtypErrorCode: PropertyType = { id: 0x000A, name: "PtypErrorCode", size: 4, multi: false } + +/**** Variable length ****/ +// Variable size; a string of Unicode characters in UTF-16LE format encoding with terminating null character (0x0000). +export const PtypString: PropertyType = { id: 0x001F, name: "PtypString", multi: false } +// Variable size; a string of multibyte characters in externally specified encoding with terminating null character (single 0 byte). +export const PtypString8: PropertyType = { id: 0x001E, name: "PtypString8", multi: false } +// Variable size; a COUNT field followed by that many bytes. +export const PtypBinary: PropertyType = { id: 0x0102, name: "PtypBinary", multi: false } +// 16 bytes; a GUID with Data1, Data2, and Data3 fields in little-endian format. +export const PtypGuid: PropertyType = { id: 0x0048, name: "PtypGuid", size: 16, multi: false } +// The property value is a Component Object Model (COM) object +export const PtypObject: PropertyType = { id: 0x000D, name: "PtypObject", multi: false } + +/**** Fixed length Multiple-Valued Properties ****/ +export const PtypMultipleInteger16: PropertyType = { id: 0x1002, name: "PtypMultipleInteger16", size: 2, multi: true } +export const PtypMultipleInteger32: PropertyType = { id: 0x1003, name: "PtypMultipleInteger32", size: 4, multi: true } +export const PtypMultipleFloating32: PropertyType = { id: 0x1004, name: "PtypMultipleFloating32", size: 4, multi: true } +export const PtypMultipleFloating64: PropertyType = { id: 0x1005, name: "PtypMultipleFloating64", size: 8, multi: true } +export const PtypMultipleCurrency: PropertyType = { id: 0x1006, name: "PtypMultipleCurrency", size: 8, multi: true } +export const PtypMultipleFloatingTime: PropertyType = { id: 0x1007, name: "PtypMultipleFloatingTime", size: 8, multi: true } +export const PtypMultipleTime: PropertyType = { id: 0x1040, name: "PtypMultipleTime", size: 8, multi: true } +export const PtypMultipleGuid: PropertyType = { id: 0x1048, name: "PtypMultipleGuid", size: 16, multi: true } +export const PtypMultipleInteger64: PropertyType = { id: 0x1014, name: "PtypMultipleInteger64", size: 8, multi: true } + +/// Variable Length Multiple-Valued Properties +export const PtypMultipleBinary: PropertyType = { id: 0x1102, name: "PtypMultipleBinary", multi: true } +export const PtypMultipleString8: PropertyType = { id: 0x101E, name: "PtypMultipleString8", multi: true } +export const PtypMultipleString: PropertyType = { id: 0x101F, name: "PtypMultipleString", multi: true } + +export const PROPERTY_TYPES = { + // Fixed length + [PtypInteger16.id]: PtypInteger16, + [PtypInteger32.id]: PtypInteger32, + [PtypFloating32.id]: PtypFloating32, + [PtypFloating64.id]: PtypFloating64, + [PtypBoolean.id]: PtypBoolean, + [PtypCurrency.id]: PtypCurrency, + [PtypFloatingTime.id]: PtypFloatingTime, + [PtypTime.id]: PtypTime, + [PtypInteger64.id]: PtypInteger64, + [PtypErrorCode.id]: PtypErrorCode, + + // Variable length + [PtypString.id]: PtypString, + [PtypString8.id]: PtypString8, + [PtypBinary.id]: PtypBinary, + [PtypGuid.id]: PtypGuid, + [PtypObject.id]: PtypObject, + + // Fixed length multivalue + [PtypMultipleInteger16.id]: PtypMultipleInteger16, + [PtypMultipleInteger32.id]: PtypMultipleInteger32, + [PtypMultipleFloating32.id]: PtypMultipleFloating32, + [PtypMultipleFloating64.id]: PtypMultipleFloating64, + [PtypMultipleCurrency.id]: PtypMultipleCurrency, + [PtypMultipleFloatingTime.id]: PtypMultipleFloatingTime, + [PtypMultipleTime.id]: PtypMultipleTime, + [PtypMultipleGuid.id]: PtypMultipleGuid, + [PtypMultipleInteger64.id]: PtypMultipleInteger64, + + // Variable Length Multivalue + [PtypMultipleBinary.id]: PtypMultipleBinary, + [PtypMultipleString8.id]: PtypMultipleString8, + [PtypMultipleString.id]: PtypMultipleString, +}; + +export interface PropertyType { + id: number, + name: string, + size?: number // in bytes, + multi: boolean, +} \ No newline at end of file diff --git a/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/streams/property/types/property-data.d.ts b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/streams/property/types/property-data.d.ts new file mode 100644 index 0000000..d6897de --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/streams/property/types/property-data.d.ts @@ -0,0 +1,49 @@ +import type { PropertyType } from "../property-types"; + +export interface PropertyData { + /** + * Property Type (2 bytes): The type of the property. + */ + propertyType: PropertyType, + + /** + * Property Id (2 bytes): The id of the property. + */ + propertyId: number, + + /** + * Flags (4 bytes): Flags giving context to the property. + * Possible values for this field are given in the following table. + * Any bitwise combination of the flags is valid. + * + * |--------------------|-------------|--------------------------------------------------------------------------------------------------| + * | Flag name | Value | Description | + * |--------------------|-------------|--------------------------------------------------------------------------------------------------| + * | PROPATTR_MANDATORY | 0x00000001 | If this flag is set for a property, that property MUST NOT be deleted from the .msg file | + * | | | (irrespective of which storage it is contained in) and implementations MUST return an error | + * | | | if any attempt is made to do so. This flag is set in circumstances where the implementation | + * | | | depends on that property always being present in the .msg file once it is written there. | + * |--------------------|-------------|------------------------------------------------------------------------------------------------- | + * | PROPATTR_READABLE | 0x00000002 | If this flag is not set on a property, that property MUST NOT be read from the .msg file and | + * | | | implementations MUST return an error if any attempt is made to read it. This flag is set on all | + * | | | properties unless there is an implementation-specific reason to prevent a property from being | + * | | | read from the .msg file. | + * |--------------------|-------------|--------------------------------------------------------------------------------------------------| + * | PROPATTR_WRITABLE | 0x00000004 | If this flag is not set on a property, that property MUST NOT be modified or deleted and | + * | | | implementations MUST return an error if any attempt is made to do so. This flag is set in | + * | | | circumstances where the implementation depends on the properties being writable. | + * |--------------------|-------------|--------------------------------------------------------------------------------------------------| + */ + flags: number, + + /** + * (8 bytes): + * - If Fixed Length Property: + * Data (variable): The value of the property. The size of this field depends upon the property type, which is specified in the Property Tag field, as specified in section 2.4.2.1. The size required for each property type is specified in [MS-OXCDATA] section 2.11.1. + * Reserved (variable): This field MUST be ignored when reading a .msg file. The size of the Reserved field is the difference between 8 bytes and the size of the Data field; if the size of the Reserved field is greater than 0, this field MUST be set to 0 when writing a .msg file. + * - If Variable Length Property: + * Size (4 bytes): This value is interpreted based on the property type, which is specified in the Property Tag field. If the message contains an embedded message attachment or a storage attachment, this field MUST be set to 0xFFFFFFFF. Otherwise, the following table shows how this field is interpreted for each property type. The property types are specified in [MS-OXCDATA] section 2.11.1. + * Reserved (4 bytes): This field MUST be ignored when reading a .msg file. + */ + valueOrSize: number | bigint, +} \ No newline at end of file diff --git a/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/streams/property/types/property-header.d.ts b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/streams/property/types/property-header.d.ts new file mode 100644 index 0000000..35bfd03 --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/streams/property/types/property-header.d.ts @@ -0,0 +1,26 @@ +export interface PropertyHeader { + /** + * Custom property used for definying the header size. Can differ based on the reserved bytes + */ + size: number + + /** + * Next Recipient ID (4 bytes): The ID to use for naming the next Recipient object storage if one is created inside the .msg file. The naming convention to be used is specified in section 2.2.1. If no Recipient object storages are contained in the .msg file, this field MUST be set to 0. + */ + nextRecipientId?: number, + + /** + * Next Attachment ID (4 bytes): The ID to use for naming the next Attachment object storage if one is created inside the .msg file. The naming convention to be used is specified in section 2.2.2. If no Attachment object storages are contained in the .msg file, this field MUST be set to 0. + */ + nextAttachmentId?: number, + + /** + * Recipient Count (4 bytes): The number of Recipient objects. + */ + recipientCount?: number, + + /** + * Attachment Count (4 bytes): The number of Attachment objects. + */ + attachmentCount?: number, +} diff --git a/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/streams/property/types/property-stream-entry.d.ts b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/streams/property/types/property-stream-entry.d.ts new file mode 100644 index 0000000..ba93d19 --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/streams/property/types/property-stream-entry.d.ts @@ -0,0 +1,7 @@ +import type { PropertyHeader } from "./property-header"; +import type { PropertyData } from "./property-data"; + +export interface PropertyStreamEntry { + header: PropertyHeader, + data: Map +} \ No newline at end of file diff --git a/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/types/message.d.ts b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/types/message.d.ts new file mode 100644 index 0000000..3896b16 --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/types/message.d.ts @@ -0,0 +1,34 @@ +export interface Message { + file: CompoundFile, + content: MessageContent, + attachments: Attachment[], + recipients: Recipient[], +} + +export interface MessageContent { + date: Date, + subject: string, + senderName: string, + senderEmail: string, + body: string, + bodyHTML: string, + bodyRTF: Buffer, + headers: string, + toRecipients: string, + ccRecipients: string +} + +export interface Attachment { + extension: string, + fileName: string, + mimeType: string, + language: string, + displayName: string, + content: Buffer, + embeddedMsgObj: DirectoryEntry +} + +export interface Recipient { + name: string, + email: string, +} \ No newline at end of file diff --git a/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/utils/file-size-util.ts b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/utils/file-size-util.ts new file mode 100644 index 0000000..5368e18 --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/utils/file-size-util.ts @@ -0,0 +1,12 @@ +const SIZE = 1000; +const UNITS = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + +export function bytesWithUnits(bytes: number): string { + let unit = 0; + while(bytes >= SIZE){ + bytes = bytes / SIZE; + unit++; + } + + return `${bytes.toFixed(bytes < 10 && unit > 0 ? 1 : 0)} ${UNITS[unit]}`; +} diff --git a/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/utils/html-template-util.ts b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/utils/html-template-util.ts new file mode 100644 index 0000000..77c0c35 --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/utils/html-template-util.ts @@ -0,0 +1,20 @@ +export function createFragmentFromTemplate(html: string, obj: T): DocumentFragment { + const element = document.createElement("div"); + element.innerHTML = fillHTMLTemplate(html, obj); + + const fragment = document.createDocumentFragment(); + while(element.children.length > 0) { + fragment.appendChild(element.children[0]); + } + + return fragment; +} + +/** + * Fills an HTML template with object values in place of {{object.key}} + */ +export function fillHTMLTemplate(html: string, obj: T): string { + return html.replace(/{{(.*?)}}/g, (_m: string, key: string) => { + return obj[key.trim() as keyof typeof obj] as string; + }) +} \ No newline at end of file diff --git a/msg_viewer/static/src/lib/msg-viewer-main/lib/styles/root.css b/msg_viewer/static/src/lib/msg-viewer-main/lib/styles/root.css new file mode 100644 index 0000000..bb32cf6 --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/lib/styles/root.css @@ -0,0 +1,131 @@ +* { + box-sizing: border-box; + padding: 0; + margin: 0; +} + +:root { + font-size: 18px; +} + +/* Common utils */ +.color-text-2 { + color: rgb(168, 168, 168); +} + +.hidden { + display: none !important; +} + +.flex-b { + display: flex; + align-items: center; + justify-content: space-between; +} + +.flex-col { + display: flex; + flex-direction: column; + gap: 5px; +} + +.btn { + all: unset; + border-radius: 6px; + cursor: pointer; + background-color: #235b8d; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + padding: 16px; +} + +.btn:hover { + opacity: 0.8; +} + +.btn-2 { + display: flex; + gap: 10px; + padding: 10px 12px; + width: fit-content; + align-items: center; + color: white; + text-decoration: none; +} + +/* Per element */ +body { + background: rgb(36, 38, 43); + color: white; + font-family: Arial, Helvetica, sans-serif; + min-height: 100vh; +} + +#root { + display: grid; + grid-template-columns: 2fr 5fr; + grid-gap: 10px; + height: 100vh; + padding: 24px; +} + +#info { + align-items: center; + gap: 50px; +} + +.info-content { + flex: 1; + gap: 15px; + justify-content: center; + text-align: center; +} + + .info-content h1 { + font-size: 2.3rem; + } + + .info-content ul { + display: flex; + gap: 8px; + list-style: none; + flex-wrap: wrap; + justify-content: center; + } + + + .info-content ul li { + padding: 10px 14px; + border-radius: 10px; + background: #475369; + } + +.info-footer { + font-size: 0.8rem; + text-align: center; +} + +.footer-link { + color: rgb(102, 172, 248); +} + +#msg { + gap: 10px; + border: 3px solid white; + border-radius: 6px; + background: #333c4d; + width: 100%; + height: 100%; + overflow: auto; +} + +@media (max-width: 700px) { + #root { + grid-template-columns: 1fr; + grid-template-rows: 1fr 3fr; + } + + #msg { + min-height: 100%; + height: fit-content; + } +} diff --git a/msg_viewer/static/src/lib/msg-viewer-main/lib/styles/styles.css b/msg_viewer/static/src/lib/msg-viewer-main/lib/styles/styles.css new file mode 100644 index 0000000..c291526 --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/lib/styles/styles.css @@ -0,0 +1,2 @@ +@import "./lib/styles/root.css"; +@import "./lib/components/styles.css"; diff --git a/msg_viewer/static/src/lib/msg-viewer-main/package-lock.json b/msg_viewer/static/src/lib/msg-viewer-main/package-lock.json new file mode 100644 index 0000000..e36ca30 --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/package-lock.json @@ -0,0 +1,761 @@ +{ + "name": "msg-viewer-ui", + "version": "1.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "msg-viewer-ui", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@molotochok/msg-viewer": "^1.0.3" + }, + "devDependencies": { + "@types/bun": "latest", + "bun-plugin-html": "^2.0.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@molotochok/msg-viewer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@molotochok/msg-viewer/-/msg-viewer-1.0.3.tgz", + "integrity": "sha512-I51IHp//YoQxv/nC5wJHYFtW3U8y7ZP4UGTKByVvmuJwQGimx/1um0Y2X0g5VaWRJiUGtISsojhG0WcNZESSxQ==" + }, + "node_modules/@types/bun": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.1.10.tgz", + "integrity": "sha512-76KYVSwrHwr9zsnk6oLXOGs9KvyBg3U066GLO4rk6JZk1ypEPGCUDZ5yOiESyIHWs9cx9iC8r01utYN32XdmgA==", + "dev": true, + "dependencies": { + "bun-types": "1.1.29" + } + }, + "node_modules/@types/node": { + "version": "20.12.14", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.14.tgz", + "integrity": "sha512-scnD59RpYD91xngrQQLGkE+6UrHUPzeKZWhhjBSa3HSkwjbQc38+q3RoIVEwxQGRw3M+j5hpNAM+lgV3cVormg==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/ws": { + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.12.tgz", + "integrity": "sha512-3tPRkv1EtkDpzlgyKyI8pGsGZAGPEaXeu0DOj5DI25Ja91bdAYddYHbADRYVrZMRbfW+1l5YwXVDKohDJNQxkQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/acorn": { + "version": "8.12.1", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "dev": true, + "license": "MIT" + }, + "node_modules/bun-plugin-html": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "clean-css": "^5.3.3", + "html-minifier-terser": "^7.2.0", + "linkedom": "^0.15.3" + } + }, + "node_modules/bun-types": { + "version": "1.1.29", + "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.1.29.tgz", + "integrity": "sha512-En3/TzSPMPyl5UlUB1MHzHpcrZDakTm7mS203eLoX1fBoEa3PW+aSS8GAqVJ7Is/m34Z5ogL+ECniLY0uDaCPw==", + "dev": true, + "dependencies": { + "@types/node": "~20.12.8", + "@types/ws": "~8.5.10" + } + }, + "node_modules/camel-case": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "pascal-case": "^3.1.2", + "tslib": "^2.0.3" + } + }, + "node_modules/clean-css": { + "version": "5.3.3", + "dev": true, + "license": "MIT", + "dependencies": { + "source-map": "~0.6.0" + }, + "engines": { + "node": ">= 10.0" + } + }, + "node_modules/commander": { + "version": "10.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/css-select": { + "version": "5.1.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.1.0", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssom": { + "version": "0.5.0", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.1.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dot-case": { + "version": "3.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/html-escaper": { + "version": "3.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/html-minifier-terser": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "camel-case": "^4.1.2", + "clean-css": "~5.3.2", + "commander": "^10.0.0", + "entities": "^4.4.0", + "param-case": "^3.0.4", + "relateurl": "^0.2.7", + "terser": "^5.15.1" + }, + "bin": { + "html-minifier-terser": "cli.js" + }, + "engines": { + "node": "^14.13.1 || >=16.0.0" + } + }, + "node_modules/htmlparser2": { + "version": "8.0.2", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, + "node_modules/linkedom": { + "version": "0.15.6", + "dev": true, + "license": "ISC", + "dependencies": { + "css-select": "^5.1.0", + "cssom": "^0.5.0", + "html-escaper": "^3.0.3", + "htmlparser2": "^8.0.1", + "uhyphen": "^0.2.0" + } + }, + "node_modules/lower-case": { + "version": "2.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/no-case": { + "version": "3.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/param-case": { + "version": "3.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/pascal-case": { + "version": "3.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/relateurl": { + "version": "0.2.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/terser": { + "version": "5.31.6", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.8.2", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "dev": true, + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.7.0", + "dev": true, + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.5.4", + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uhyphen": { + "version": "0.2.0", + "dev": true, + "license": "ISC" + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + } + }, + "dependencies": { + "@jridgewell/gen-mapping": { + "version": "0.3.5", + "dev": true, + "requires": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "@jridgewell/resolve-uri": { + "version": "3.1.2", + "dev": true + }, + "@jridgewell/set-array": { + "version": "1.2.1", + "dev": true + }, + "@jridgewell/source-map": { + "version": "0.3.6", + "dev": true, + "requires": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "dev": true + }, + "@jridgewell/trace-mapping": { + "version": "0.3.25", + "dev": true, + "requires": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "@molotochok/msg-viewer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@molotochok/msg-viewer/-/msg-viewer-1.0.3.tgz", + "integrity": "sha512-I51IHp//YoQxv/nC5wJHYFtW3U8y7ZP4UGTKByVvmuJwQGimx/1um0Y2X0g5VaWRJiUGtISsojhG0WcNZESSxQ==" + }, + "@types/bun": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.1.10.tgz", + "integrity": "sha512-76KYVSwrHwr9zsnk6oLXOGs9KvyBg3U066GLO4rk6JZk1ypEPGCUDZ5yOiESyIHWs9cx9iC8r01utYN32XdmgA==", + "dev": true, + "requires": { + "bun-types": "1.1.29" + } + }, + "@types/node": { + "version": "20.12.14", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.14.tgz", + "integrity": "sha512-scnD59RpYD91xngrQQLGkE+6UrHUPzeKZWhhjBSa3HSkwjbQc38+q3RoIVEwxQGRw3M+j5hpNAM+lgV3cVormg==", + "dev": true, + "requires": { + "undici-types": "~5.26.4" + } + }, + "@types/ws": { + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.12.tgz", + "integrity": "sha512-3tPRkv1EtkDpzlgyKyI8pGsGZAGPEaXeu0DOj5DI25Ja91bdAYddYHbADRYVrZMRbfW+1l5YwXVDKohDJNQxkQ==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "acorn": { + "version": "8.12.1", + "dev": true + }, + "boolbase": { + "version": "1.0.0", + "dev": true + }, + "buffer-from": { + "version": "1.1.2", + "dev": true + }, + "bun-plugin-html": { + "version": "2.0.0", + "dev": true, + "requires": { + "clean-css": "^5.3.3", + "html-minifier-terser": "^7.2.0", + "linkedom": "^0.15.3" + } + }, + "bun-types": { + "version": "1.1.29", + "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.1.29.tgz", + "integrity": "sha512-En3/TzSPMPyl5UlUB1MHzHpcrZDakTm7mS203eLoX1fBoEa3PW+aSS8GAqVJ7Is/m34Z5ogL+ECniLY0uDaCPw==", + "dev": true, + "requires": { + "@types/node": "~20.12.8", + "@types/ws": "~8.5.10" + } + }, + "camel-case": { + "version": "4.1.2", + "dev": true, + "requires": { + "pascal-case": "^3.1.2", + "tslib": "^2.0.3" + } + }, + "clean-css": { + "version": "5.3.3", + "dev": true, + "requires": { + "source-map": "~0.6.0" + } + }, + "commander": { + "version": "10.0.1", + "dev": true + }, + "css-select": { + "version": "5.1.0", + "dev": true, + "requires": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + } + }, + "css-what": { + "version": "6.1.0", + "dev": true + }, + "cssom": { + "version": "0.5.0", + "dev": true + }, + "dom-serializer": { + "version": "2.0.0", + "dev": true, + "requires": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + } + }, + "domelementtype": { + "version": "2.3.0", + "dev": true + }, + "domhandler": { + "version": "5.0.3", + "dev": true, + "requires": { + "domelementtype": "^2.3.0" + } + }, + "domutils": { + "version": "3.1.0", + "dev": true, + "requires": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + } + }, + "dot-case": { + "version": "3.0.4", + "dev": true, + "requires": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "entities": { + "version": "4.5.0", + "dev": true + }, + "html-escaper": { + "version": "3.0.3", + "dev": true + }, + "html-minifier-terser": { + "version": "7.2.0", + "dev": true, + "requires": { + "camel-case": "^4.1.2", + "clean-css": "~5.3.2", + "commander": "^10.0.0", + "entities": "^4.4.0", + "param-case": "^3.0.4", + "relateurl": "^0.2.7", + "terser": "^5.15.1" + } + }, + "htmlparser2": { + "version": "8.0.2", + "dev": true, + "requires": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, + "linkedom": { + "version": "0.15.6", + "dev": true, + "requires": { + "css-select": "^5.1.0", + "cssom": "^0.5.0", + "html-escaper": "^3.0.3", + "htmlparser2": "^8.0.1", + "uhyphen": "^0.2.0" + } + }, + "lower-case": { + "version": "2.0.2", + "dev": true, + "requires": { + "tslib": "^2.0.3" + } + }, + "no-case": { + "version": "3.0.4", + "dev": true, + "requires": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, + "nth-check": { + "version": "2.1.1", + "dev": true, + "requires": { + "boolbase": "^1.0.0" + } + }, + "param-case": { + "version": "3.0.4", + "dev": true, + "requires": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "pascal-case": { + "version": "3.1.2", + "dev": true, + "requires": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "relateurl": { + "version": "0.2.7", + "dev": true + }, + "source-map": { + "version": "0.6.1", + "dev": true + }, + "source-map-support": { + "version": "0.5.21", + "dev": true, + "requires": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "terser": { + "version": "5.31.6", + "dev": true, + "requires": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.8.2", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "dependencies": { + "commander": { + "version": "2.20.3", + "dev": true + } + } + }, + "tslib": { + "version": "2.7.0", + "dev": true + }, + "typescript": { + "version": "5.5.4", + "peer": true + }, + "uhyphen": { + "version": "0.2.0", + "dev": true + }, + "undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + } + } +} diff --git a/msg_viewer/static/src/lib/msg-viewer-main/package.json b/msg_viewer/static/src/lib/msg-viewer-main/package.json new file mode 100644 index 0000000..c1780d1 --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer-main/package.json @@ -0,0 +1,20 @@ +{ + "name": "msg-viewer-ui", + "version": "1.0.0", + "author": "Markiian Karpa", + "main": "index.js", + "devDependencies": { + "@types/bun": "latest", + "bun-plugin-html": "^2.0.0" + }, + "scripts": { + "clean": "rm -rf build" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "license": "MIT", + "dependencies": { + "@molotochok/msg-viewer": "^1.0.3" + } +} diff --git a/msg_viewer/static/src/lib/msg-viewer.js b/msg_viewer/static/src/lib/msg-viewer.js new file mode 100644 index 0000000..17edd4f --- /dev/null +++ b/msg_viewer/static/src/lib/msg-viewer.js @@ -0,0 +1,36 @@ +// Import the required modules +import { MsgParser } from './scripts/msg/msg-parser'; +import { HtmlTemplateUtil } from './scripts/utils/html-template-util'; + +// Function to load and display the MSG file +async function loadMsgFile(url) { + try { + // Fetch the MSG file + const response = await fetch(url); + const arrayBuffer = await response.arrayBuffer(); + + // Parse the MSG file + const parser = new MsgParser(arrayBuffer); + const message = await parser.parse(); + + // Create HTML from the message + const html = HtmlTemplateUtil.createMessageHtml(message); + + // Display the message + document.body.innerHTML = html; + } catch (error) { + console.error('Error loading MSG file:', error); + document.body.innerHTML = `
Error loading MSG file: ${error.message}
`; + } +} + +// Get the file URL from the query parameters +const urlParams = new URLSearchParams(window.location.search); +const fileUrl = urlParams.get('file'); + +// Load the MSG file if a URL is provided +if (fileUrl) { + loadMsgFile(fileUrl); +} else { + document.body.innerHTML = '
No MSG file URL provided
'; +} diff --git a/msg_viewer/static/src/lib/msg-viewer.zip b/msg_viewer/static/src/lib/msg-viewer.zip new file mode 100644 index 0000000..8d6a23e Binary files /dev/null and b/msg_viewer/static/src/lib/msg-viewer.zip differ diff --git a/msg_viewer/static/src/lib/resources/google331b65fa04565532.html b/msg_viewer/static/src/lib/resources/google331b65fa04565532.html new file mode 100644 index 0000000..b7121c2 --- /dev/null +++ b/msg_viewer/static/src/lib/resources/google331b65fa04565532.html @@ -0,0 +1 @@ +google-site-verification: google331b65fa04565532.html \ No newline at end of file diff --git a/msg_viewer/static/src/lib/resources/robots.txt b/msg_viewer/static/src/lib/resources/robots.txt new file mode 100644 index 0000000..3dcd2c3 --- /dev/null +++ b/msg_viewer/static/src/lib/resources/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: \ No newline at end of file diff --git a/msg_viewer/static/src/lib/scripts/index.ts b/msg_viewer/static/src/lib/scripts/index.ts new file mode 100644 index 0000000..10d90b3 --- /dev/null +++ b/msg_viewer/static/src/lib/scripts/index.ts @@ -0,0 +1,63 @@ +import { messageFragment } from "../components/message"; +import { errorFragment } from "../components/error"; +import type { Message } from "./msg/types/message"; +import { parse, parseDir } from "@molotochok/msg-viewer"; + +const $file = document.getElementById("file")!; + +$file.addEventListener("change", async (event) => { + const target = event.target as HTMLInputElement; + if (target?.files?.length === 0) return; + updateMessage(target.files!); +}); + +// To reset the file input +$file.addEventListener("click", (event) => (event.target as HTMLInputElement).value = ""); + + +const target = document.documentElement; +target.addEventListener("dragover", (event) => event.preventDefault()); +target.addEventListener("drop", (event) => { + event.preventDefault(); + + const files = event.dataTransfer!.files; + if (files.length == 0) return; + if (!files[0].name.endsWith(".msg")) return; + + const $file = document.getElementById("file")! as HTMLInputElement; + $file.files = files; + updateMessage(files); +}); + +async function updateMessage(files: FileList) { + const arrayBuffer = await files[0].arrayBuffer(); + const $msg = document.getElementById("msg")!; + renderMessage($msg, + () => parse(new DataView(arrayBuffer)), + (fragment) => $msg.replaceChildren(fragment) + ); +} + +function renderMessage($msg: HTMLElement, getMessage: () => Message, updateDom: (fragment: DocumentFragment) => void) { + let fragment: DocumentFragment; + try { + const message = getMessage(); + fragment = messageFragment(message, dir => { + renderMessage($msg, + () => parseDir(message.file, dir), + (fragment) => { + for (let i = 0; i < $msg.children.length; i++) { + const child = $msg.children[i] as HTMLElement; + child.classList.add("hidden"); + }; + $msg.appendChild(fragment) + } + ); + }); + } catch (e) { + window.gtag('event', 'exception', { 'description': e, 'fatal': true }); + fragment = errorFragment(`An error occured during the parsing of the .msg file. Error: ${e}`); + } + + updateDom(fragment); +} \ No newline at end of file diff --git a/msg_viewer/static/src/lib/scripts/msg/README.md b/msg_viewer/static/src/lib/scripts/msg/README.md new file mode 100644 index 0000000..c627316 --- /dev/null +++ b/msg_viewer/static/src/lib/scripts/msg/README.md @@ -0,0 +1,37 @@ +
+ Icon +

https://msg-viewer.pages.dev/

+

+ +### Description +This library allows you to read Outlook `.msg` file in browser. + +### Install +Simply run this command in terminal: +``` +npm i @molotochok/msg-viewer +``` + +### Usage +First import the library: + +```js +import { parse } from "@molotochok/msg-viewer"; +``` + +Then parse the input array buffer: +```js +const message = parse(new DataView(arrayBuffer)); +``` + +The result is an object that contains relevant information about the message. It's structure is defined [here](https://github.com/molotochok/msg-viewer/blob/main/lib/scripts/msg/types/message.d.ts#L1). + +### Example +Refer to this [page](https://github.com/molotochok/msg-viewer/blob/main/lib/scripts/index.ts#L34) for the example usage of this library in the live experience. + + +### Repository +You can find the source code on [Github](https://github.com/molotochok/msg-viewer). + +### Support +If you wish to support me you can by me a [coffee](https://buymeacoffee.com/markian98f). diff --git a/msg_viewer/static/src/lib/scripts/msg/compound-file/compound-file.ts b/msg_viewer/static/src/lib/scripts/msg/compound-file/compound-file.ts new file mode 100644 index 0000000..c75c4cf --- /dev/null +++ b/msg_viewer/static/src/lib/scripts/msg/compound-file/compound-file.ts @@ -0,0 +1,84 @@ +import { getDifat } from "./difat"; +import { Directory } from "./directory/directory"; +import type { DirectoryEntry } from "./directory/types/directory-entry"; +import { getFat } from "./fat"; +import { getHeader, type Header } from "./header"; +import { getMiniFat } from "./mini-fat"; +import { streamSectorOffset } from "./util"; + +export class CompoundFile { + constructor( + public readonly view: DataView, + public readonly header: Header, + public readonly difat: number[], + public readonly fat: number[], + public readonly miniFat: number[], + public readonly directory: Directory, + ) {} + + static create(view: DataView) { + const header = getHeader(view); + const difat = getDifat(view, header); + const fat = getFat(view, header, difat); + const miniFat = getMiniFat(view, header, fat); + const directory = Directory.getDirectory(view, header, fat); + + return new CompoundFile(view, header, difat, fat, miniFat, directory); + } + + readStream( + entry: DirectoryEntry, + getDataAction: (offset: number, bytesToRead: number) => void, + entrySize?: number, + getHeaderAction?: (offset: number) => number + ) { + let sector = entry.startingSectorLocation; + if (sector >= 0xFFFFFFFE) return; + + let sectorSize = this.header.sectorSize; + let fat = this.fat; + + if (entry.streamSize < this.header.miniStreamCutOffSize) { + sectorSize = this.header.miniSectorSize; + fat = this.miniFat; + } + + let offset = streamSectorOffset(sector, this.header, entry.streamSize, this.directory.miniStreamLocations); + let initialOffset = offset; + + const headerSize = getHeaderAction ? getHeaderAction(offset) : 0; + let streamSize = entry.streamSize - BigInt(headerSize); + offset += headerSize; + entrySize = entrySize ?? Math.min(Number(streamSize), this.header.miniSectorSize); + + while(streamSize > 0) { + if (offset - initialOffset >= sectorSize) { + sector = fat[sector]; + if (sector >= 0xFFFFFFFE) break; + + offset = streamSectorOffset(sector, this.header, entry.streamSize, this.directory.miniStreamLocations); + initialOffset = offset; + } + + let bytes = Math.min(entrySize, Number(streamSize)); + getDataAction(offset, bytes); + + streamSize -= BigInt(bytes); + offset += bytes; + } + } + + toString(): string { + const obj = { + header: this.header, + difat: this.difat, + fat: this.fat, + miniFat: + this.miniFat, + directory: + this.directory + }; + + return JSON.stringify(obj, (_, v) => typeof v === 'bigint' ? v.toString() : v, "\t"); + } +} \ No newline at end of file diff --git a/msg_viewer/static/src/lib/scripts/msg/compound-file/constants/text-decoder.ts b/msg_viewer/static/src/lib/scripts/msg/compound-file/constants/text-decoder.ts new file mode 100644 index 0000000..2e424ee --- /dev/null +++ b/msg_viewer/static/src/lib/scripts/msg/compound-file/constants/text-decoder.ts @@ -0,0 +1 @@ +export const TEXT_DECODER = new TextDecoder("UTF-16LE"); \ No newline at end of file diff --git a/msg_viewer/static/src/lib/scripts/msg/compound-file/difat.ts b/msg_viewer/static/src/lib/scripts/msg/compound-file/difat.ts new file mode 100644 index 0000000..bc4b8ff --- /dev/null +++ b/msg_viewer/static/src/lib/scripts/msg/compound-file/difat.ts @@ -0,0 +1,34 @@ +import type { Header } from "./header"; +import { sectorOffset } from "./util"; + +/** + * Returns an array of FAT Sector Locations which specify the FAT sector number. + * DIFAT is used to find all FAT sectors. + */ +export function getDifat(view: DataView, header: Header): number[] { + // - If Header Major Version is 3, there MUST be 127 fields specified to fill a 512-byte sector minus the "Next DIFAT Sector Location" field. + // - If Header Major Version is 4, there MUST be 1,023 fields specified to fill a 4,096-byte sector minus the "Next DIFAT Sector Location" field. + const fatNumber = header.majorVersion == 3 ? 127 : 1023; + + const difat = Array.from(header.difat); + + // Next DIFAT Sector Location (4 bytes): This field specifies the next sector number in the DIFAT chain of sectors. The first DIFAT sector is specified in the Header. The last DIFAT sector MUST set this field to ENDOFCHAIN (0xFFFFFFFE). + let sector = header.firstDifatSectorLocation; + while (sector < 0xFFFFFFFE) { + let offset = sectorOffset(sector, header.sectorSize); + + for (let i = 0; i < fatNumber; i++) { + if (view.getUint32(offset, true) == 0xFFFFFFFF) { + offset += (fatNumber - i) * 4; + break; + } + + difat.push(view.getUint32(offset, true)); + offset += 4; + } + + sector = view.getUint32(offset, true); + } + + return difat; +} \ No newline at end of file diff --git a/msg_viewer/static/src/lib/scripts/msg/compound-file/directory/directory.ts b/msg_viewer/static/src/lib/scripts/msg/compound-file/directory/directory.ts new file mode 100644 index 0000000..19bf544 --- /dev/null +++ b/msg_viewer/static/src/lib/scripts/msg/compound-file/directory/directory.ts @@ -0,0 +1,157 @@ +import { ColorFlag } from "./enums/color-flag"; +import type { DirectoryEntry } from "./types/directory-entry"; +import { ObjectType } from "./enums/object-type"; +import type { Header } from "../header"; +import { sectorOffset } from "../util"; +import { TEXT_DECODER } from "../constants/text-decoder"; + +export class Directory { + constructor( + public readonly entries: DirectoryEntry[], + public readonly miniStreamLocations: number[] + ) {} + + static getDirectory(view: DataView, header: Header, fat: number[]): Directory { + const entrySize = 128; + const entriesCount = header.sectorSize / entrySize; + + const entries: DirectoryEntry[] = []; + + let sector = header.firstDirSectorLocation; + while (sector < 0xFFFFFFFE) { + let offset = sectorOffset(sector, header.sectorSize); + + for (let i = 0; i < entriesCount; i++) { + entries.push(this.directoryEntry(view, offset)); + + offset += entrySize; + } + + sector = fat[sector]; + } + + return new Directory( + entries, + this.getMiniStreamLocations(entries[0].startingSectorLocation, fat), + ); + } + + /** + * Traverses a Red-Black Tree to find the entry with the given name. + */ + get(name: string, root: number, deep: boolean): DirectoryEntry | null { + if (root < 0 || root >= this.entries.length) return null; + + const entry = this.entries[root]; + if (!entry) return null; + + const diff = this.compareName(name, entry.entryName); + + if (diff < 0) { + const left = this.get(name, entry.leftSiblingId, deep); + if (left) return left; + }else if (diff > 0) { + const right = this.get(name, entry.rightSiblingId, deep); + if (right) return right; + } else { + return entry; + } + + return deep ? this.get(name, entry.childId, deep) : null; + } + + private static getMiniStreamLocations(sector: number, fat: number[]): number[] { + const locations: number[] = []; + + while(sector < 0xFFFFFFFE) { + locations.push(sector); + sector = fat[sector]; + } + + return locations; + } + + private static directoryEntry(view: DataView, offset: number): DirectoryEntry { + const entryNameLength = view.getUint16(offset + 64, true); + const entryName = entryNameLength > 0 + ? TEXT_DECODER.decode(new DataView(view.buffer, offset, entryNameLength - 2)) + : ""; + offset += 66; + + const objectType = view.getUint8(offset) as ObjectType; + offset += 1; + + const colorFlag = view.getUint8(offset) as ColorFlag; + offset += 1; + + const leftSiblingId = view.getUint32(offset, true); + offset += 4; + + const rightSiblingId = view.getUint32(offset, true); + offset += 4; + + const childId = view.getUint32(offset, true); + offset += 4; + + const clsid = ""; + offset += 16; + + const stateBits = view.getUint32(offset, true); + offset += 4; + + const creationTime = view.getBigUint64(offset, true); + offset += 8; + + const modifiedTime = view.getBigUint64(offset, true); + offset += 8; + + const startingSectorLocation = view.getUint32(offset, true); + offset += 4; + + const streamSize = view.getBigUint64(offset, true); + offset += 8; + + return { + entryName, + entryNameLength, + objectType, + colorFlag, + leftSiblingId, + rightSiblingId, + childId, + clsid, + stateBits, + creationTime, + modifiedTime, + startingSectorLocation, + streamSize + } + } + + print() { + this.traverse((entry, depth) => console.log("\t".repeat(depth), entry.entryName)); + } + + traverse(action: (entry: DirectoryEntry, depth: number) => void) { + this.traverseFromRoot(0, 0, action); + } + + private traverseFromRoot(root: number, depth: number, action: (entry: DirectoryEntry, depth: number) => void) { + if (root < 0 || root >= this.entries.length) return; + + const entry = this.entries[root]; + if (!entry) return; + + this.traverseFromRoot(entry.leftSiblingId, depth, action); + this.traverseFromRoot(entry.rightSiblingId, depth, action); + + action(entry, depth); + this.traverseFromRoot(entry.childId, depth + 1, action); + } + + private compareName(name1: string, name2: string) { + if (name1.length < name2.length) return -1; + if (name1.length > name2.length) return 1; + return name1.toUpperCase().localeCompare(name2.toUpperCase()); + } +} \ No newline at end of file diff --git a/msg_viewer/static/src/lib/scripts/msg/compound-file/directory/enums/color-flag.ts b/msg_viewer/static/src/lib/scripts/msg/compound-file/directory/enums/color-flag.ts new file mode 100644 index 0000000..10d81c5 --- /dev/null +++ b/msg_viewer/static/src/lib/scripts/msg/compound-file/directory/enums/color-flag.ts @@ -0,0 +1,4 @@ +export enum ColorFlag { + Red = 0x00, + Black = 0x01 +} diff --git a/msg_viewer/static/src/lib/scripts/msg/compound-file/directory/enums/object-type.ts b/msg_viewer/static/src/lib/scripts/msg/compound-file/directory/enums/object-type.ts new file mode 100644 index 0000000..77cd5f2 --- /dev/null +++ b/msg_viewer/static/src/lib/scripts/msg/compound-file/directory/enums/object-type.ts @@ -0,0 +1,6 @@ +export enum ObjectType { + Unknown = 0x00, + Storage = 0x01, + Stream = 0x02, + RootStorage = 0x05 +} diff --git a/msg_viewer/static/src/lib/scripts/msg/compound-file/directory/types/directory-entry.d.ts b/msg_viewer/static/src/lib/scripts/msg/compound-file/directory/types/directory-entry.d.ts new file mode 100644 index 0000000..ac79eec --- /dev/null +++ b/msg_viewer/static/src/lib/scripts/msg/compound-file/directory/types/directory-entry.d.ts @@ -0,0 +1,132 @@ +export interface DirectoryEntry { + /** + * Directory Entry Name (64 bytes): This field MUST contain a Unicode string for the storage or + * stream name encoded in UTF-16. The name MUST be terminated with a UTF-16 terminating null + * character. Thus, storage and stream names are limited to 32 UTF-16 code points, including the + * terminating null character. When locating an object in the compound file except for the root + * storage, the directory entry name is compared by using a special case-insensitive uppercase + * mapping, described in Red-Black Tree. The following characters are illegal and MUST NOT be part + * of the name: '/', '\', ':', '!'. + */ + entryName: string, + + /** + * Directory Entry Name Length (2 bytes): This field MUST match the length of the Directory Entry + * Name Unicode string in bytes. The length MUST be a multiple of 2 and include the terminating null + * character in the count. This length MUST NOT exceed 64, the maximum size of the Directory Entry + * Name field. + */ + entryNameLength: number, + + /** + * Object Type (1 byte): This field MUST be 0x00, 0x01, 0x02, or 0x05, depending on the actual type of object. All other values are not valid. + */ + objectType: ObjectType, + + /** + * Color Flag (1 byte): This field MUST be 0x00 (red) or 0x01 (black). All other values are not valid + */ + colorFlag: ColorFlag, + + /** + * Left Sibling ID (4 bytes): This field contains the stream ID of the left sibling. If there is no left sibling, the field MUST be set to NOSTREAM (0xFFFFFFFF). + * --------------------------------------------------------------------------------- + * Value | Meaning + * -------------------------------|------------------------------------------------- + * REGSID 0x00000000 — 0xFFFFFFF9 | Regular stream ID to identify the directory entry. + * MAXREGSID 0xFFFFFFFA | Maximum regular stream ID. + * NOSTREAM 0xFFFFFFFF | If there is no left sibling. + */ + leftSiblingId: number, + + /** + * Right Sibling ID (4 bytes): This field contains the stream ID of the right sibling. If there is no right sibling, the field MUST be set to NOSTREAM (0xFFFFFFFF). + * --------------------------------------------------------------------------------- + * Value | Meaning + * -------------------------------|------------------------------------------------- + * REGSID 0x00000000 — 0xFFFFFFF9 | Regular stream ID to identify the directory entry. + * MAXREGSID 0xFFFFFFFA | Maximum regular stream ID. + * NOSTREAM 0xFFFFFFFF | If there is no right sibling. + */ + rightSiblingId: number, + + /** + * Child ID (4 bytes): This field contains the stream ID of a child object. If there is no child object, including all entries for stream objects, the field MUST be set to NOSTREAM (0xFFFFFFFF). + * --------------------------------------------------------------------------------- + * Value | Meaning + * -------------------------------|------------------------------------------------- + * REGSID 0x00000000 — 0xFFFFFFF9 | Regular stream ID to identify the directory entry. + * MAXREGSID 0xFFFFFFFA | Maximum regular stream ID. + * NOSTREAM 0xFFFFFFFF | If there is no child object. + */ + childId: number, + + /** + * CLSID (16 bytes): This field contains an object class GUID, if this entry is for a storage object or + * root storage object. For a stream object, this field MUST be set to all zeroes. A value containing all + * zeroes in a storage or root storage directory entry is valid, and indicates that no object class is + * associated with the storage. If an implementation of the file format enables applications to create + * storage objects without explicitly setting an object class GUID, it MUST write all zeroes by default. + * If this value is not all zeroes, the object class GUID can be used as a parameter to start + * applications. + * + * ----------------------------------------------------------------------------------- + * Value | Meaning + * -----------------------------------|----------------------------------------------- + * 0x00000000000000000000000000000000 | No object class is associated with the storage. + */ + clsid: string, + + /** + * State Bits (4 bytes): This field contains the user-defined flags if this entry is for a storage object or + * root storage object. For a stream object, this field SHOULD be set to all zeroes because many + * implementations provide no way for applications to retrieve state bits from a stream object. If an + * implementation of the file format enables applications to create storage objects without explicitly + * setting state bits, it MUST write all zeroes by default. + * -------------------------------------------------------------------------------- + * Value | Meaning + * ------------|------------------------------------------------------------------- + * 0x00000000 | Default value when no state bits are explicitly set on the object. + */ + stateBits: number, + + /** + * Creation Time (8 bytes): This field contains the creation time for a storage object, or all zeroes to + * indicate that the creation time of the storage object was not recorded. The Windows FILETIME + * structure is used to represent this field in UTC. For a stream object, this field MUST be all zeroes. + * For a root storage object, this field MUST be all zeroes, and the creation time is retrieved or set on + * the compound file itself. + * ------------------------------------------------------------------- + * Value | Meaning + * --------------------|---------------------------------------------- + * 0x0000000000000000 | No creation time was recorded for the object. + */ + creationTime: bigint, + + /** + * Modified Time (8 bytes): This field contains the modification time for a storage object, or all + * zeroes to indicate that the modified time of the storage object was not recorded. The Windows + * FILETIME structure is used to represent this field in UTC. For a stream object, this field MUST be + * all zeroes. For a root storage object, this field MAY<2> be set to all zeroes, and the modified time + * is retrieved or set on the compound file itself. + * ------------------------------------------------------------------- + * Value | Meaning + * --------------------|---------------------------------------------- + * 0x0000000000000000 | No modified time was recorded for the object. + */ + modifiedTime: bigint, + + /** + * Starting Sector Location (4 bytes): This field contains the first sector location if this is a stream + * object. For a root storage object, this field MUST contain the first sector of the mini stream, if the + * mini stream exists. For a storage object, this field MUST be set to all zeroes. + */ + startingSectorLocation: number, + + /** + * Stream Size (8 bytes): This 64-bit integer field contains the size of the user-defined data if this is + * a stream object. For a root storage object, this field contains the size of the mini stream. For a + * storage object, this field MUST be set to all zeroes. + */ + streamSize: bigint +} \ No newline at end of file diff --git a/msg_viewer/static/src/lib/scripts/msg/compound-file/fat.ts b/msg_viewer/static/src/lib/scripts/msg/compound-file/fat.ts new file mode 100644 index 0000000..c2f719d --- /dev/null +++ b/msg_viewer/static/src/lib/scripts/msg/compound-file/fat.ts @@ -0,0 +1,24 @@ +import type { Header } from "./header"; +import { fatSectorSize, sectorOffset } from "./util"; + +/** + * The FAT is an array of sector numbers that represent the allocation of space within the file, grouped + * into FAT sectors. Each stream is represented in the FAT by a sector chain, in much the same fashion + * as a FAT file system. + */ +export function getFat(view: DataView, header: Header, difat: number[]): number[] { + const sectorSize = fatSectorSize(header); + + const fat: number[] = []; + for (let i = 0; i < difat.length; i++) { + let offset = sectorOffset(difat[i], header.sectorSize); + + for (let j = 0; j < sectorSize; j++) { + const nextSector = view.getUint32(offset, true); + fat.push(nextSector); + offset += 4; + } + } + + return fat; +} \ No newline at end of file diff --git a/msg_viewer/static/src/lib/scripts/msg/compound-file/header.ts b/msg_viewer/static/src/lib/scripts/msg/compound-file/header.ts new file mode 100644 index 0000000..c4f621d --- /dev/null +++ b/msg_viewer/static/src/lib/scripts/msg/compound-file/header.ts @@ -0,0 +1,125 @@ +export interface Header { + // Header Signature (8 bytes): Identification signature for the compound file structure, and MUST be set to the value 0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1. + signature: number[], + + // Minor Version (2 bytes): Version number for nonbreaking changes. This field SHOULD be set to 0x003E if the major version field is either 0x0003 or 0x0004. + minorVersion: number, + + // Major Version (2 bytes): Version number for breaking changes. This field MUST be set to either 0x0003 (version 3) or 0x0004 (version 4). + majorVersion: number, + + // Byte Order (2 bytes): This field MUST be set to 0xFFFE. This field is a byte order mark for all integer fields, specifying little-endian byte order. + byteOrder: number, + + // Sector Shift (2 bytes): This field MUST be set to 0x0009, or 0x000c, depending on the Major Version field. + // Sector Size = 2 ^ SectorShift. e.g. 2 ^ 9 = 512, 2 ^ 12 = 4096 + sectorSize: number, + + // Mini Sector Shift (2 bytes): This field MUST be set to 0x0006. This field specifies the sector size of the Mini Stream as a power of 2. The sector size of the Mini Stream MUST be 64 bytes. + // Mini Sector Size = 2 ^ Mini Sector Shift. e.g. 2 ^ 6 = 64 + miniSectorSize: number, + + // Number of Directory Sectors (4 bytes): This integer field contains the count of the number of directory sectors in the compound file. + numberOfDirectorySectors: number, + + // Number of FAT Sectors (4 bytes): This integer field contains the count of the number of FAT sectors in the compound file. + numberOfFatSectors: number, + + // First Directory Sector Location (4 bytes): This integer field contains the starting sector number for the directory stream. + firstDirSectorLocation: number, + + // Transaction Signature Number (4 bytes): This integer field MAY contain a sequence number that is incremented every time the compound file is saved by an implementation that supports file transactions. This is the field that MUST be set to all zeroes if file transactions are not implemented. + transactionSignatureNumber: number, + + // Mini Stream Cutoff Size (4 bytes): This integer field MUST be set to 0x00001000. This field specifies the maximum size of a user-defined data stream that is allocated from the mini FAT and mini stream, and that cutoff is 4,096 bytes. Any user-defined data stream that is greater than or equal to this cutoff size must be allocated as normal sectors from the FAT. + miniStreamCutOffSize: number, + + // First Mini FAT Sector Location (4 bytes): This integer field contains the starting sector number for the mini FAT. + firstMiniFatSectorLocation: number, + + // Number of Mini FAT Sectors (4 bytes): This integer field contains the count of the number of mini FAT sectors in the compound file. + numberOfMiniFatSectors: number, + + // First DIFAT Sector Location (4 bytes): This integer field contains the starting sector number for the DIFAT. + firstDifatSectorLocation: number, + + // Number of DIFAT Sectors (4 bytes): This integer field contains the count of the number of DIFAT sectors in the compound file. + numberOfDifatSectors: number, + + // DIFAT (436 bytes): This array of 32-bit integer fields contains the first 109 FAT sector locations of the compound file. For version 4 compound files, the header size (512 bytes) is less than the sector size (4,096 bytes), so the remaining part of the header (3,584 bytes) MUST be filled with all zeroes. + // The DIFAT array is used to represent storage of the FAT sectors. + difat: number[] +} + +export function getHeader(view: DataView): Header { + const signature = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]; + for (var offset = 0; offset < 8; offset++) { + if (view.getUint8(offset) != signature[offset]) { + throw new Error("Signature mismatch!"); + } + } + + // Header CLSID (16 bytes): Reserved and unused class ID that MUST be set to all zeroes + offset += 16; + + const minorVersion = view.getUint16(offset, true); + offset += 2; + + const majorVersion = view.getUint16(offset, true); + offset += 2; + + const byteOrder = view.getUint16(offset, true); + offset += 2; + + const sectorSize = Math.pow(2, view.getUint16(offset, true)); + offset += 2; + + const miniSectorSize = Math.pow(2, view.getUint16(offset, true)); + offset += 2; + + // Reserved (6 bytes): This field MUST be set to all zeroes. + offset += 6; + + const numberOfDirectorySectors = view.getUint32(offset, true) + offset += 4; + + const numberOfFatSectors = view.getUint32(offset, true) + offset += 4; + + const firstDirSectorLocation = view.getUint32(offset, true); + offset += 4; + + const transactionSignatureNumber = view.getUint32(offset, true); + offset += 4; + + const miniStreamCutOffSize = view.getUint32(offset, true); + offset += 4; + + const firstMiniFatSectorLocation = view.getUint32(offset, true); + offset += 4; + + const numberOfMiniFatSectors = view.getUint32(offset, true) + offset += 4; + + const firstDifatSectorLocation = view.getUint32(offset, true) + offset += 4; + + const numberOfDifatSectors = view.getUint32(offset, true); + offset += 4; + + const difat: number[] = []; + for (let i = 0; i < 436; i += 4) { + const fat = view.getUint32(offset, true); + if (fat == 0xFFFFFFFF) break; + + difat.push(fat); + offset += 4; + } + + return { + signature, byteOrder, sectorSize, miniSectorSize, minorVersion, majorVersion, numberOfDirectorySectors, + numberOfFatSectors, firstDirSectorLocation, transactionSignatureNumber, + miniStreamCutOffSize, firstMiniFatSectorLocation, numberOfMiniFatSectors, + firstDifatSectorLocation, numberOfDifatSectors, difat + } +} diff --git a/msg_viewer/static/src/lib/scripts/msg/compound-file/mini-fat.ts b/msg_viewer/static/src/lib/scripts/msg/compound-file/mini-fat.ts new file mode 100644 index 0000000..f67ca5f --- /dev/null +++ b/msg_viewer/static/src/lib/scripts/msg/compound-file/mini-fat.ts @@ -0,0 +1,26 @@ +import type { Header } from "./header"; +import { fatSectorSize, sectorOffset } from "./util"; + +/** + * The mini FAT is used to allocate space in the mini stream. The mini stream is divided into smaller, + * equal-length sectors, and the sector size that is used for the mini stream is specified from the + * Compound File Header (64 bytes). + */ +export function getMiniFat(view: DataView, header: Header, fat: number[]): number[] { + const sectorSize = fatSectorSize(header); + + const miniFat = []; + + let sector = header.firstMiniFatSectorLocation; + while (sector < 0xFFFFFFFE) { + let offset = sectorOffset(sector, header.sectorSize); + for (let i = 0; i < sectorSize; i++) { + miniFat.push(view.getUint32(offset, true)); + offset += 4; + } + + sector = fat[sector]; + } + + return miniFat; +} \ No newline at end of file diff --git a/msg_viewer/static/src/lib/scripts/msg/compound-file/util.ts b/msg_viewer/static/src/lib/scripts/msg/compound-file/util.ts new file mode 100644 index 0000000..c9e63a1 --- /dev/null +++ b/msg_viewer/static/src/lib/scripts/msg/compound-file/util.ts @@ -0,0 +1,31 @@ +import type { Header } from "./header"; + +/** + * Based on a non-stream sector number returns the byte offset in the Buffer. + */ +export function sectorOffset(sector: number, sectorSize: number): number { + return (sector + 1) * sectorSize; +} + +/** + * Based on a stream sector number returns the byte offset in the Buffer. + */ +export function streamSectorOffset(sector: number, header: Header, streamSize: bigint, miniStreamLocations: number[]): number { + let offset = sectorOffset(sector, header.sectorSize); + + // Calculates the offset in ministream if streamSize is smaller than miniStreamCutOffSize + if (streamSize < header.miniStreamCutOffSize) { + offset = sector * header.miniSectorSize; + const miniStreamSector = Math.floor(offset / header.sectorSize); + const miniStreamOffset = offset % header.sectorSize; + offset = sectorOffset(miniStreamLocations[miniStreamSector], header.sectorSize) + miniStreamOffset ; + } + + return offset; +} + +export function fatSectorSize(header: Header) { + // If Header Major Version is 3, there MUST be 128 fields specified to fill a 512-byte sector. + // If Header Major Version is 4, there MUST be 1,024 fields specified to fill a 4,096-byte sector. + return header.majorVersion == 3 ? 128 : 1024; +} diff --git a/msg_viewer/static/src/lib/scripts/msg/msg-parser.ts b/msg_viewer/static/src/lib/scripts/msg/msg-parser.ts new file mode 100644 index 0000000..a2e52e9 --- /dev/null +++ b/msg_viewer/static/src/lib/scripts/msg/msg-parser.ts @@ -0,0 +1,110 @@ +import { CompoundFile } from "./compound-file/compound-file"; +import { TEXT_DECODER } from "./compound-file/constants/text-decoder"; +import type { DirectoryEntry } from "./compound-file/directory/types/directory-entry"; +import { ATTACH_PROPERTIES, PropertySource, RECIP_PROPERTIES, ROOT_PROPERTIES, type Property } from "./streams/property/properties"; +import { getPropertyStreamEntry } from "./streams/property/property-stream"; +import { PtypBinary, PtypObject, PtypString, PtypTime, type PropertyType } from "./streams/property/property-types"; +import type { PropertyStreamEntry } from "./streams/property/types/property-stream-entry"; +import type { Attachment, Message, MessageContent, Recipient } from "./types/message"; + +export function parse(view: DataView): Message { + const file = CompoundFile.create(view); + const dir = file.directory.entries[0]; + + return parseDir(file, dir); +} + +export function parseDir(file: CompoundFile, dir: DirectoryEntry): Message { + const pStreamEntry = getPropertyStreamEntry(file, dir)!; + + return { + file: file, + content: getContent(file, dir, pStreamEntry), + attachments: getAttachments(file, dir), + recipients: getRecipients(file, dir), + }; +} + +function getContent(file: CompoundFile, dir: DirectoryEntry, pStreamEntry: PropertyStreamEntry): MessageContent { + return getValue(file, ROOT_PROPERTIES, dir, pStreamEntry); +} + +function getRecipients(file: CompoundFile, dir: DirectoryEntry): Recipient[] { + return getValues(file, dir, RECIP_PROPERTIES, "recip"); +} + +function getAttachments(file: CompoundFile, dir: DirectoryEntry): Attachment[] { + return getValues(file, dir, ATTACH_PROPERTIES, "attach"); +} + +function getValues(file: CompoundFile, dir: DirectoryEntry, properties: Property[], prefix: string): T[] { + const list: T[] = []; + + for (let i = 0; i < 2048; i++) { + const directory = file.directory.get(`__${prefix}_version1.0_#${i.toString(16).padStart(8, "0")}`, dir.childId, false); + if (!directory) break; + + const pStreamEntry = getPropertyStreamEntry(file, directory)!; + list.push(getValue(file, properties, directory, pStreamEntry)); + } + + return list; +} + +function getValue(file: CompoundFile, properties: Property[], dir: DirectoryEntry, entry: PropertyStreamEntry): T { + return properties.reduce((acc, p) => { + if (p.source == PropertySource.Stream) { + const streamName = `__substg1.0_${p.id.padStart(4, "0")}${p.type.id.toString(16).padStart(4, "0")}`; + const entry = file.directory.get(streamName, dir.childId, false); + if (!entry) return acc; + acc[p.name as keyof T] = getValueFromStream(file, entry, p.type) as T[keyof T]; + } else { + const value = getValueFromProperty(entry, p); + if (!value) return acc; + acc[p.name as keyof T] = value as T[keyof T]; + } + + return acc; + }, {} as T); +} + +function getValueFromProperty(entry: PropertyStreamEntry, property: Property) { + const value = entry.data.get(property.id.toLowerCase())?.valueOrSize; + if (!value) return ""; + + switch (property.type) { + case PtypTime: { + // Subtracting the number of seconds between January 1, 1601 and January 1, 1970. + return new Date(Number(value as bigint / 10000n) - 1.16444736e13); + } + default: return value; + } +} + +function getValueFromStream(file: CompoundFile, entry: DirectoryEntry, type: PropertyType) { + switch (type) { + case PtypString: { + let value = ""; + file.readStream(entry, (offset, bytes) => { + value += TEXT_DECODER.decode(new DataView(file.view.buffer, offset, bytes)); + }); + + return value; + }; + case PtypBinary: { + const chunks = new Uint8Array(Number(entry.streamSize)); + let pos = 0; + file.readStream(entry, (offset, bytes) => { + const chunk = file.view.buffer.slice(offset, offset + bytes); + chunks.set(new Uint8Array(chunk), pos); + pos += bytes; + }); + + return new DataView(chunks.buffer); + }; + case PtypObject: { + return entry; + } + default: return null; + }; +} diff --git a/msg_viewer/static/src/lib/scripts/msg/package.json b/msg_viewer/static/src/lib/scripts/msg/package.json new file mode 100644 index 0000000..d2fff6c --- /dev/null +++ b/msg_viewer/static/src/lib/scripts/msg/package.json @@ -0,0 +1,8 @@ +{ + "name": "@molotochok/msg-viewer", + "version": "1.0.3", + "author": "Markiian Karpa", + "main": "msg-parser.ts", + "description": "This tool allows you to read Outlook .msg file.", + "license": "MIT" +} \ No newline at end of file diff --git a/msg_viewer/static/src/lib/scripts/msg/rtf/decompressor/crc.ts b/msg_viewer/static/src/lib/scripts/msg/rtf/decompressor/crc.ts new file mode 100644 index 0000000..e8e82b9 --- /dev/null +++ b/msg_viewer/static/src/lib/scripts/msg/rtf/decompressor/crc.ts @@ -0,0 +1,19 @@ +const TABLE = [0x00000000, 0x77073096, 0xee0e612c, 0x990951ba, 0x076dc419, 0x706af48f, 0xe963a535, 0x9e6495a3, 0x0edb8832, 0x79dcb8a4, 0xe0d5e91e, 0x97d2d988, 0x09b64c2b, 0x7eb17cbd, 0xe7b82d07, 0x90bf1d91, 0x1db71064, 0x6ab020f2, 0xf3b97148, 0x84be41de, 0x1adad47d, 0x6ddde4eb, 0xf4d4b551, 0x83d385c7, 0x136c9856, 0x646ba8c0, 0xfd62f97a, 0x8a65c9ec, 0x14015c4f, 0x63066cd9, 0xfa0f3d63, 0x8d080df5, 0x3b6e20c8, 0x4c69105e, 0xd56041e4, 0xa2677172, 0x3c03e4d1, 0x4b04d447, 0xd20d85fd, 0xa50ab56b, 0x35b5a8fa, 0x42b2986c, 0xdbbbc9d6, 0xacbcf940, 0x32d86ce3, 0x45df5c75, 0xdcd60dcf, 0xabd13d59, 0x26d930ac, 0x51de003a, 0xc8d75180, 0xbfd06116, 0x21b4f4b5, 0x56b3c423, 0xcfba9599, 0xb8bda50f, 0x2802b89e, 0x5f058808, 0xc60cd9b2, 0xb10be924, 0x2f6f7c87, 0x58684c11, 0xc1611dab, 0xb6662d3d, 0x76dc4190, 0x01db7106, 0x98d220bc, 0xefd5102a, 0x71b18589, 0x06b6b51f, 0x9fbfe4a5, 0xe8b8d433, 0x7807c9a2, 0x0f00f934, 0x9609a88e, 0xe10e9818, 0x7f6a0dbb, 0x086d3d2d, 0x91646c97, 0xe6635c01, 0x6b6b51f4, 0x1c6c6162, 0x856530d8, 0xf262004e, 0x6c0695ed, 0x1b01a57b, 0x8208f4c1, 0xf50fc457, 0x65b0d9c6, 0x12b7e950, 0x8bbeb8ea, 0xfcb9887c, 0x62dd1ddf, 0x15da2d49, 0x8cd37cf3, 0xfbd44c65, 0x4db26158, 0x3ab551ce, 0xa3bc0074, 0xd4bb30e2, 0x4adfa541, 0x3dd895d7, 0xa4d1c46d, 0xd3d6f4fb, 0x4369e96a, 0x346ed9fc, 0xad678846, 0xda60b8d0, 0x44042d73, 0x33031de5, 0xaa0a4c5f, 0xdd0d7cc9, 0x5005713c, 0x270241aa, 0xbe0b1010, 0xc90c2086, 0x5768b525, 0x206f85b3, 0xb966d409, 0xce61e49f, 0x5edef90e, 0x29d9c998, 0xb0d09822, 0xc7d7a8b4, 0x59b33d17, 0x2eb40d81, 0xb7bd5c3b, 0xc0ba6cad, 0xedb88320, 0x9abfb3b6, 0x03b6e20c, 0x74b1d29a, 0xead54739, 0x9dd277af, 0x04db2615, 0x73dc1683, 0xe3630b12, 0x94643b84, 0x0d6d6a3e, 0x7a6a5aa8, 0xe40ecf0b, 0x9309ff9d, 0x0a00ae27, 0x7d079eb1, 0xf00f9344, 0x8708a3d2, 0x1e01f268, 0x6906c2fe, 0xf762575d, 0x806567cb, 0x196c3671, 0x6e6b06e7, 0xfed41b76, 0x89d32be0, 0x10da7a5a, 0x67dd4acc, 0xf9b9df6f, 0x8ebeeff9, 0x17b7be43, 0x60b08ed5, 0xd6d6a3e8, 0xa1d1937e, 0x38d8c2c4, 0x4fdff252, 0xd1bb67f1, 0xa6bc5767, 0x3fb506dd, 0x48b2364b, 0xd80d2bda, 0xaf0a1b4c, 0x36034af6, 0x41047a60, 0xdf60efc3, 0xa867df55, 0x316e8eef, 0x4669be79, 0xcb61b38c, 0xbc66831a, 0x256fd2a0, 0x5268e236, 0xcc0c7795, 0xbb0b4703, 0x220216b9, 0x5505262f, 0xc5ba3bbe, 0xb2bd0b28, 0x2bb45a92, 0x5cb36a04, 0xc2d7ffa7, 0xb5d0cf31, 0x2cd99e8b, 0x5bdeae1d, 0x9b64c2b0, 0xec63f226, 0x756aa39c, 0x026d930a, 0x9c0906a9, 0xeb0e363f, 0x72076785, 0x05005713, 0x95bf4a82, 0xe2b87a14, 0x7bb12bae, 0x0cb61b38, 0x92d28e9b, 0xe5d5be0d, 0x7cdcefb7, 0x0bdbdf21, 0x86d3d2d4, 0xf1d4e242, 0x68ddb3f8, 0x1fda836e, 0x81be16cd, 0xf6b9265b, 0x6fb077e1, 0x18b74777, 0x88085ae6, 0xff0f6a70, 0x66063bca, 0x11010b5c, 0x8f659eff, 0xf862ae69, 0x616bffd3, 0x166ccf45, 0xa00ae278, 0xd70dd2ee, 0x4e048354, 0x3903b3c2, 0xa7672661, 0xd06016f7, 0x4969474d, 0x3e6e77db, 0xaed16a4a, 0xd9d65adc, 0x40df0b66, 0x37d83bf0, 0xa9bcae53, 0xdebb9ec5, 0x47b2cf7f, 0x30b5ffe9, 0xbdbdf21c, 0xcabac28a, 0x53b39330, 0x24b4a3a6, 0xbad03605, 0xcdd70693, 0x54de5729, 0x23d967bf, 0xb3667a2e, 0xc4614ab8, 0x5d681b02, 0x2a6f2b94, 0xb40bbe37, 0xc30c8ea1, 0x5a05df1b, 0x2d02ef8d]; + +/** + * Cyclic redundancy check which is used to check if the RTF file is corrupted. + * It must be equal to the value taken from the header. + */ +export function crc(view: DataView, headerSize: number) { + let tablePosition = 0, intermidiateValue = 0, crcValue = 0; + + for (let i = headerSize; i < view.byteLength; i++) { + tablePosition = (crcValue ^ view.getUint8(i)) & 0xFF; + intermidiateValue = crcValue >>> 8; + // >>> 0 is needed to avoid a negative number. + // In this way JS will treat the number as unsigned. + crcValue = (TABLE[tablePosition] ^ intermidiateValue) >>> 0; + } + + return crcValue; +} diff --git a/msg_viewer/static/src/lib/scripts/msg/rtf/decompressor/dictionary.ts b/msg_viewer/static/src/lib/scripts/msg/rtf/decompressor/dictionary.ts new file mode 100644 index 0000000..755ca21 --- /dev/null +++ b/msg_viewer/static/src/lib/scripts/msg/rtf/decompressor/dictionary.ts @@ -0,0 +1,14 @@ +const STR = "{\\rtf1\\ansi\\mac\\deff0\\deftab720{\\fonttbl;}{\\f0\\fnil \\froman \\fswiss \\fmodern \\fscript \\fdecor MS Sans SerifSymbolArialTimes New RomanCourier{\\colortbl\\red0\\green0\\blue0\r\n\\par \\pard\\plain\\f0\\fs20\\b\\i\\u\\tab\\tx"; + +let dict: Uint8Array | null = null; + +export function getDictionary(): Uint8Array { + if (!dict) { + dict = new Uint8Array(4096); + for (let i = 0; i < STR.length; i++) { + dict[i] = STR.charCodeAt(i); + } + } + + return dict; +} \ No newline at end of file diff --git a/msg_viewer/static/src/lib/scripts/msg/rtf/decompressor/header.ts b/msg_viewer/static/src/lib/scripts/msg/rtf/decompressor/header.ts new file mode 100644 index 0000000..b7f32d5 --- /dev/null +++ b/msg_viewer/static/src/lib/scripts/msg/rtf/decompressor/header.ts @@ -0,0 +1,49 @@ +export function getHeader(view: DataView): Header { + let offset = 0; + + const compSize = view.getUint32(offset, true); + offset += 4; + + const rawSize = view.getUint32(offset, true); + offset += 4; + + const compType = view.getUint32(offset, true) === 0x75465A4C ? CompType.Compressed : CompType.Uncompressed; + offset += 4; + + const crc = view.getUint32(offset, true); + offset += 4; + + return { compSize, rawSize, compType, crc, headerSize: offset }; +} + +export interface Header { + /** + * Writers MUST set the COMPSIZE field to the length of the compressed data + * (the CONTENTS field) in bytes plus 12 (the count of the remaining bytes from the header). + */ + compSize: number; + + /** + * The size in bytes of the uncompressed content. + */ + rawSize: number; + + /** + * The type of compression. + */ + compType: CompType; + + /** + * Cyclic redundancy check. Used to verify that the file is not broken. + * If the COMPTYPE field is set to COMPRESSED, then the CRC field is computed from the CONTENTS field. + * If the COMPTYPE field is set to UNCOMPRESSED, then the CRC field MUST be set to %x00.00.00.00. + */ + crc: number; + + /** + * The size in bytes of the header. + */ + headerSize: number; +} + +export const enum CompType { Compressed, Uncompressed } \ No newline at end of file diff --git a/msg_viewer/static/src/lib/scripts/msg/rtf/rtf-decompressor.ts b/msg_viewer/static/src/lib/scripts/msg/rtf/rtf-decompressor.ts new file mode 100644 index 0000000..f27a426 --- /dev/null +++ b/msg_viewer/static/src/lib/scripts/msg/rtf/rtf-decompressor.ts @@ -0,0 +1,69 @@ +import { crc } from "./decompressor/crc"; +import { getDictionary } from "./decompressor/dictionary"; +import { CompType, getHeader, type Header } from "./decompressor/header"; + +export function decompressRTF(view: DataView): string { + const header = getHeader(view); + return getContent(view, header); +} + +function getContent(view: DataView, header: Header): string { + if (header.compType == CompType.Uncompressed) { + const buffer = new Uint8Array(view.buffer.slice(header.headerSize, header.headerSize + header.rawSize)); + return String.fromCharCode(...buffer); + } + + const currentCRC = crc(view, header.headerSize); + if (currentCRC !== header.crc) { + throw new Error(`CRC mismatch! Expected ${header.crc}, got ${currentCRC}.`); + } + + const output = []; + const dictionary = getDictionary(); + + let offset = header.headerSize; + let writeOffset = 207; + let readOffset = 0; + let canRun = true; + while (offset <= header.compSize + 4 && canRun) { + const control = view.getUint8(offset); + offset += 1; + + for (let i = 0; i < 8; i++) { + const bit = (control >>> i) & 1; + + if (bit == 0) { + const literal = view.getUint8(offset); + offset += 1; + + dictionary[writeOffset] = literal; + writeOffset = (writeOffset + 1) % dictionary.length; + + output.push(literal); + } else { + const ref = view.getUint16(offset); + const refOffset = ref >>> 4; + offset += 2; + + if (refOffset == writeOffset) { + canRun = false; + break; + } + + readOffset = refOffset; + const refLength = 2 + (ref & 0x0f); + for (let j = 0; j < refLength; j++) { + const byte = dictionary[readOffset]; + readOffset = (readOffset + 1) % dictionary.length; + + dictionary[writeOffset] = byte; + writeOffset = (writeOffset + 1) % dictionary.length; + + output.push(byte); + } + } + } + } + + return String.fromCharCode(...output); +} diff --git a/msg_viewer/static/src/lib/scripts/msg/streams/constants/folders.ts b/msg_viewer/static/src/lib/scripts/msg/streams/constants/folders.ts new file mode 100644 index 0000000..3f815b3 --- /dev/null +++ b/msg_viewer/static/src/lib/scripts/msg/streams/constants/folders.ts @@ -0,0 +1 @@ +export const NAME_ID_FOLDER_NAME = "__nameid_version1.0"; \ No newline at end of file diff --git a/msg_viewer/static/src/lib/scripts/msg/streams/entry/entry-stream.ts b/msg_viewer/static/src/lib/scripts/msg/streams/entry/entry-stream.ts new file mode 100644 index 0000000..08e0fa1 --- /dev/null +++ b/msg_viewer/static/src/lib/scripts/msg/streams/entry/entry-stream.ts @@ -0,0 +1,45 @@ +import type { CompoundFile } from "../../compound-file/compound-file"; +import { NAME_ID_FOLDER_NAME } from "../constants/folders"; +import type { EntryStreamData } from "./types/entry-stream-entry"; + +const DATA_SIZE = 8; +const STREAM_NAME = "__substg1.0_00030102"; + +export function getEntryStreamData(file: CompoundFile): EntryStreamData[] | null { + const folder = file.directory.get(NAME_ID_FOLDER_NAME, file.directory.entries[0].childId, false); + if (!folder) return null; + + const entry = file.directory.get(STREAM_NAME, folder.childId, false); + if (!entry) return null; + + const list: EntryStreamData[] = []; + file.readStream(entry, + (offset, bytes) => { + const data = getData(file.view, offset); + list.push(data); + }, + DATA_SIZE, + ); + + return list; +} + +function getData(view: DataView, offset: number): EntryStreamData { + const nameIdOrStringOffset = view.getUint32(offset, true); + offset += 4; + + const guidWithKind = view.getUint16(offset, true); + const propertyKind = guidWithKind & 1; + const guidIndex = guidWithKind >> 1; + offset += 2; + + const propertyIndex = view.getUint16(offset, true) + offset += 2; + + return { + nameIdOrStringOffset, + guidIndex, + propertyIndex, + propertyKind + } +} \ No newline at end of file diff --git a/msg_viewer/static/src/lib/scripts/msg/streams/entry/types/entry-stream-entry.ts b/msg_viewer/static/src/lib/scripts/msg/streams/entry/types/entry-stream-entry.ts new file mode 100644 index 0000000..0bf51b4 --- /dev/null +++ b/msg_viewer/static/src/lib/scripts/msg/streams/entry/types/entry-stream-entry.ts @@ -0,0 +1,26 @@ +export interface EntryStreamData { + /** + * Name Identifier/String Offset (4 bytes): If this property is a numerical named property (as specified by the Property Kind subfield of the Index and Kind Information field), this value is the LID part of the PropertyName structure, as specified in [MS-OXCDATA] section 2.6.1. If this property is a string named property, this value is the offset in bytes into the strings stream where the value of the Name field of the PropertyName structure is located. + */ + nameIdOrStringOffset: number, + + /** + * Property Index (2 bytes): Sequentially increasing, zero-based index. This MUST be 0 for the first named property, 1 for the second, and so on. + */ + propertyIndex: number, + + /** + * GUID Index (15 bits): Index into the GUID stream. The possible values are shown in the following table. + */ + guidIndex: number, + + /** + * Property Kind (1 bit): Bit indicating the type of the property; zero (0) if numerical named property and 1 if string named property. + */ + propertyKind: PropertyKind +} + +export enum PropertyKind { + NUMERICAL = 0, + STRING = 1 +} \ No newline at end of file diff --git a/msg_viewer/static/src/lib/scripts/msg/streams/property/properties.ts b/msg_viewer/static/src/lib/scripts/msg/streams/property/properties.ts new file mode 100644 index 0000000..8b920c9 --- /dev/null +++ b/msg_viewer/static/src/lib/scripts/msg/streams/property/properties.ts @@ -0,0 +1,41 @@ +import { PtypBinary, PtypObject, PtypString, PtypTime, type PropertyType } from "./property-types"; + +export const enum PropertySource { + Stream, // Property can be found in a dedicated stream + Property // Property is located in property stream +} + +export const ROOT_PROPERTIES: Property[] = [ + { id: "0E06", name:"date", type: PtypTime, source: PropertySource.Property }, + { id: "0037", name:"subject", type: PtypString, source: PropertySource.Stream }, + { id: "0c1a", name:"senderName", type: PtypString, source: PropertySource.Stream }, + { id: "5d02", name:"senderEmail", type: PtypString, source: PropertySource.Stream }, + { id: "1000", name:"body", type: PtypString, source: PropertySource.Stream }, + { id: "1013", name:"bodyHTML", type: PtypString, source: PropertySource.Stream }, + { id: "1009", name:"bodyRTF", type: PtypBinary, source: PropertySource.Stream }, + { id: "007d", name:"headers", type: PtypString, source: PropertySource.Stream }, + { id: "0E04", name:"toRecipients", type: PtypString, source: PropertySource.Stream }, + { id: "0E03", name:"ccRecipients", type: PtypString, source: PropertySource.Stream }, +]; + +export const ATTACH_PROPERTIES: Property[]= [ + { id: "3703", name:"extension", type: PtypString, source: PropertySource.Stream }, + { id: "3707", name:"fileName", type: PtypString, source: PropertySource.Stream }, + { id: "370e", name:"mimeType", type: PtypString, source: PropertySource.Stream }, + { id: "3A0C", name:"language", type: PtypString, source: PropertySource.Stream }, + { id: "3001", name:"displayName", type: PtypString, source: PropertySource.Stream }, + { id: "3701", name:"content", type: PtypBinary, source: PropertySource.Stream }, + { id: "3701", name:"embeddedMsgObj", type: PtypObject, source: PropertySource.Stream }, +]; + +export const RECIP_PROPERTIES: Property[] = [ + { id: "3001", name:"name", type: PtypString, source: PropertySource.Stream }, + { id: "39fe", name:"email", type: PtypString, source: PropertySource.Stream }, +]; + +export interface Property { + id: string, + name: string, + type: PropertyType, + source: PropertySource, +} diff --git a/msg_viewer/static/src/lib/scripts/msg/streams/property/property-stream.ts b/msg_viewer/static/src/lib/scripts/msg/streams/property/property-stream.ts new file mode 100644 index 0000000..9a57367 --- /dev/null +++ b/msg_viewer/static/src/lib/scripts/msg/streams/property/property-stream.ts @@ -0,0 +1,88 @@ +import type { CompoundFile } from "../../compound-file/compound-file"; +import type { DirectoryEntry } from "../../compound-file/directory/types/directory-entry"; +import { PROPERTY_TYPES } from "./property-types"; +import type { PropertyHeader } from "./types/property-header"; +import type { PropertyData } from "./types/property-data"; +import type { PropertyStreamEntry } from "./types/property-stream-entry"; + +const STREAM_NAME = "__properties_version1.0"; +const DATA_SIZE = 16; + +export function getPropertyStreamEntry(file: CompoundFile, folder: DirectoryEntry): PropertyStreamEntry | null { + const entry = file.directory.get(STREAM_NAME, folder.childId, false); + if (!entry) return null; + + let header; + const data = new Map(); + file.readStream(entry, + (offset) => { + const property = getProperty(file.view, offset); + data.set(property.propertyId.toString(16).toLowerCase().padStart(4, "0"), property); + }, + DATA_SIZE, + (offset) => { + header = getHeader(file.view, offset, folder.entryName); + return header.size; + } + ); + + return { header: header!, data: data }; +} + +function getProperty(view: DataView, offset: number): PropertyData { + const propertyTag = view.getUint32(offset, true); + const propertyType = PROPERTY_TYPES[0xFFFF & propertyTag]; + const propertyId = propertyTag >>> 16; + + offset += 4; + + const flags = view.getUint32(offset, true); + offset += 4; + + const valueOrSize = (!propertyType?.size || propertyType?.multi) + ? view.getUint32(offset, true) + : (propertyType.size == 1) + ? view.getUint8(offset) + : propertyType.size == 2 + ? view.getUint16(offset, true) + : propertyType.size == 4 + ? view.getUint32(offset, true) + : view.getBigUint64(offset, true); + + return { + propertyType, + propertyId, + flags, + valueOrSize + }; +} + +function getHeader(view: DataView, offset: number, folderName: string): PropertyHeader { + if (["__attach", "__recip"].some(v => folderName.startsWith(v))) return { size: 8 }; + + const initialOffset = offset; + + // Reserved (8 bytes): This field MUST be set to zero when writing a .msg file and MUST be ignored when reading a .msg file. + offset += 8; + + const nextRecipientId = view.getUint32(offset, true); + offset += 4; + + const nextAttachmentId = view.getUint32(offset, true); + offset += 4; + + const recipientCount = view.getUint32(offset, true); + offset += 4; + + const attachmentCount = view.getUint32(offset, true); + offset += 4; + + if (folderName.startsWith("Root")) { + // Reserved (8 bytes): This field MUST be set to 0 when writing a .msg file and MUST be ignored when reading a .msg file. + offset += 8; + } + + + return { size: offset - initialOffset, nextRecipientId, nextAttachmentId, recipientCount, attachmentCount }; +} + diff --git a/msg_viewer/static/src/lib/scripts/msg/streams/property/property-types.ts b/msg_viewer/static/src/lib/scripts/msg/streams/property/property-types.ts new file mode 100644 index 0000000..dbe5141 --- /dev/null +++ b/msg_viewer/static/src/lib/scripts/msg/streams/property/property-types.ts @@ -0,0 +1,83 @@ +/**** Fixed length ****/ +export const PtypInteger16: PropertyType = { id: 0x0002, name: "PtypInteger16", size: 2, multi: false } +export const PtypInteger32: PropertyType = { id: 0x0003, name: "PtypInteger32", size: 4, multi: false } +export const PtypFloating32: PropertyType = { id: 0x0004, name: "PtypFloating32", size: 4, multi: false } +export const PtypFloating64: PropertyType = { id: 0x0005, name: "PtypFloating64", size: 8, multi: false } +export const PtypBoolean: PropertyType = { id: 0x000B, name: "PtypBoolean", size: 1, multi: false } +export const PtypCurrency: PropertyType = { id: 0x0006, name: "PtypCurrency", size: 8, multi: false } +export const PtypFloatingTime: PropertyType = { id: 0x0007, name: "PtypFloatingTime", size: 8, multi: false } +export const PtypTime: PropertyType = { id: 0x0040, name: "PtypTime", size: 8, multi: false } +export const PtypInteger64: PropertyType = { id: 0x0014, name: "PtypInteger64", size: 8, multi: false } +export const PtypErrorCode: PropertyType = { id: 0x000A, name: "PtypErrorCode", size: 4, multi: false } + +/**** Variable length ****/ +// Variable size; a string of Unicode characters in UTF-16LE format encoding with terminating null character (0x0000). +export const PtypString: PropertyType = { id: 0x001F, name: "PtypString", multi: false } +// Variable size; a string of multibyte characters in externally specified encoding with terminating null character (single 0 byte). +export const PtypString8: PropertyType = { id: 0x001E, name: "PtypString8", multi: false } +// Variable size; a COUNT field followed by that many bytes. +export const PtypBinary: PropertyType = { id: 0x0102, name: "PtypBinary", multi: false } +// 16 bytes; a GUID with Data1, Data2, and Data3 fields in little-endian format. +export const PtypGuid: PropertyType = { id: 0x0048, name: "PtypGuid", size: 16, multi: false } +// The property value is a Component Object Model (COM) object +export const PtypObject: PropertyType = { id: 0x000D, name: "PtypObject", multi: false } + +/**** Fixed length Multiple-Valued Properties ****/ +export const PtypMultipleInteger16: PropertyType = { id: 0x1002, name: "PtypMultipleInteger16", size: 2, multi: true } +export const PtypMultipleInteger32: PropertyType = { id: 0x1003, name: "PtypMultipleInteger32", size: 4, multi: true } +export const PtypMultipleFloating32: PropertyType = { id: 0x1004, name: "PtypMultipleFloating32", size: 4, multi: true } +export const PtypMultipleFloating64: PropertyType = { id: 0x1005, name: "PtypMultipleFloating64", size: 8, multi: true } +export const PtypMultipleCurrency: PropertyType = { id: 0x1006, name: "PtypMultipleCurrency", size: 8, multi: true } +export const PtypMultipleFloatingTime: PropertyType = { id: 0x1007, name: "PtypMultipleFloatingTime", size: 8, multi: true } +export const PtypMultipleTime: PropertyType = { id: 0x1040, name: "PtypMultipleTime", size: 8, multi: true } +export const PtypMultipleGuid: PropertyType = { id: 0x1048, name: "PtypMultipleGuid", size: 16, multi: true } +export const PtypMultipleInteger64: PropertyType = { id: 0x1014, name: "PtypMultipleInteger64", size: 8, multi: true } + +/// Variable Length Multiple-Valued Properties +export const PtypMultipleBinary: PropertyType = { id: 0x1102, name: "PtypMultipleBinary", multi: true } +export const PtypMultipleString8: PropertyType = { id: 0x101E, name: "PtypMultipleString8", multi: true } +export const PtypMultipleString: PropertyType = { id: 0x101F, name: "PtypMultipleString", multi: true } + +export const PROPERTY_TYPES = { + // Fixed length + [PtypInteger16.id]: PtypInteger16, + [PtypInteger32.id]: PtypInteger32, + [PtypFloating32.id]: PtypFloating32, + [PtypFloating64.id]: PtypFloating64, + [PtypBoolean.id]: PtypBoolean, + [PtypCurrency.id]: PtypCurrency, + [PtypFloatingTime.id]: PtypFloatingTime, + [PtypTime.id]: PtypTime, + [PtypInteger64.id]: PtypInteger64, + [PtypErrorCode.id]: PtypErrorCode, + + // Variable length + [PtypString.id]: PtypString, + [PtypString8.id]: PtypString8, + [PtypBinary.id]: PtypBinary, + [PtypGuid.id]: PtypGuid, + [PtypObject.id]: PtypObject, + + // Fixed length multivalue + [PtypMultipleInteger16.id]: PtypMultipleInteger16, + [PtypMultipleInteger32.id]: PtypMultipleInteger32, + [PtypMultipleFloating32.id]: PtypMultipleFloating32, + [PtypMultipleFloating64.id]: PtypMultipleFloating64, + [PtypMultipleCurrency.id]: PtypMultipleCurrency, + [PtypMultipleFloatingTime.id]: PtypMultipleFloatingTime, + [PtypMultipleTime.id]: PtypMultipleTime, + [PtypMultipleGuid.id]: PtypMultipleGuid, + [PtypMultipleInteger64.id]: PtypMultipleInteger64, + + // Variable Length Multivalue + [PtypMultipleBinary.id]: PtypMultipleBinary, + [PtypMultipleString8.id]: PtypMultipleString8, + [PtypMultipleString.id]: PtypMultipleString, +}; + +export interface PropertyType { + id: number, + name: string, + size?: number // in bytes, + multi: boolean, +} \ No newline at end of file diff --git a/msg_viewer/static/src/lib/scripts/msg/streams/property/types/property-data.d.ts b/msg_viewer/static/src/lib/scripts/msg/streams/property/types/property-data.d.ts new file mode 100644 index 0000000..d6897de --- /dev/null +++ b/msg_viewer/static/src/lib/scripts/msg/streams/property/types/property-data.d.ts @@ -0,0 +1,49 @@ +import type { PropertyType } from "../property-types"; + +export interface PropertyData { + /** + * Property Type (2 bytes): The type of the property. + */ + propertyType: PropertyType, + + /** + * Property Id (2 bytes): The id of the property. + */ + propertyId: number, + + /** + * Flags (4 bytes): Flags giving context to the property. + * Possible values for this field are given in the following table. + * Any bitwise combination of the flags is valid. + * + * |--------------------|-------------|--------------------------------------------------------------------------------------------------| + * | Flag name | Value | Description | + * |--------------------|-------------|--------------------------------------------------------------------------------------------------| + * | PROPATTR_MANDATORY | 0x00000001 | If this flag is set for a property, that property MUST NOT be deleted from the .msg file | + * | | | (irrespective of which storage it is contained in) and implementations MUST return an error | + * | | | if any attempt is made to do so. This flag is set in circumstances where the implementation | + * | | | depends on that property always being present in the .msg file once it is written there. | + * |--------------------|-------------|------------------------------------------------------------------------------------------------- | + * | PROPATTR_READABLE | 0x00000002 | If this flag is not set on a property, that property MUST NOT be read from the .msg file and | + * | | | implementations MUST return an error if any attempt is made to read it. This flag is set on all | + * | | | properties unless there is an implementation-specific reason to prevent a property from being | + * | | | read from the .msg file. | + * |--------------------|-------------|--------------------------------------------------------------------------------------------------| + * | PROPATTR_WRITABLE | 0x00000004 | If this flag is not set on a property, that property MUST NOT be modified or deleted and | + * | | | implementations MUST return an error if any attempt is made to do so. This flag is set in | + * | | | circumstances where the implementation depends on the properties being writable. | + * |--------------------|-------------|--------------------------------------------------------------------------------------------------| + */ + flags: number, + + /** + * (8 bytes): + * - If Fixed Length Property: + * Data (variable): The value of the property. The size of this field depends upon the property type, which is specified in the Property Tag field, as specified in section 2.4.2.1. The size required for each property type is specified in [MS-OXCDATA] section 2.11.1. + * Reserved (variable): This field MUST be ignored when reading a .msg file. The size of the Reserved field is the difference between 8 bytes and the size of the Data field; if the size of the Reserved field is greater than 0, this field MUST be set to 0 when writing a .msg file. + * - If Variable Length Property: + * Size (4 bytes): This value is interpreted based on the property type, which is specified in the Property Tag field. If the message contains an embedded message attachment or a storage attachment, this field MUST be set to 0xFFFFFFFF. Otherwise, the following table shows how this field is interpreted for each property type. The property types are specified in [MS-OXCDATA] section 2.11.1. + * Reserved (4 bytes): This field MUST be ignored when reading a .msg file. + */ + valueOrSize: number | bigint, +} \ No newline at end of file diff --git a/msg_viewer/static/src/lib/scripts/msg/streams/property/types/property-header.d.ts b/msg_viewer/static/src/lib/scripts/msg/streams/property/types/property-header.d.ts new file mode 100644 index 0000000..35bfd03 --- /dev/null +++ b/msg_viewer/static/src/lib/scripts/msg/streams/property/types/property-header.d.ts @@ -0,0 +1,26 @@ +export interface PropertyHeader { + /** + * Custom property used for definying the header size. Can differ based on the reserved bytes + */ + size: number + + /** + * Next Recipient ID (4 bytes): The ID to use for naming the next Recipient object storage if one is created inside the .msg file. The naming convention to be used is specified in section 2.2.1. If no Recipient object storages are contained in the .msg file, this field MUST be set to 0. + */ + nextRecipientId?: number, + + /** + * Next Attachment ID (4 bytes): The ID to use for naming the next Attachment object storage if one is created inside the .msg file. The naming convention to be used is specified in section 2.2.2. If no Attachment object storages are contained in the .msg file, this field MUST be set to 0. + */ + nextAttachmentId?: number, + + /** + * Recipient Count (4 bytes): The number of Recipient objects. + */ + recipientCount?: number, + + /** + * Attachment Count (4 bytes): The number of Attachment objects. + */ + attachmentCount?: number, +} diff --git a/msg_viewer/static/src/lib/scripts/msg/streams/property/types/property-stream-entry.d.ts b/msg_viewer/static/src/lib/scripts/msg/streams/property/types/property-stream-entry.d.ts new file mode 100644 index 0000000..ba93d19 --- /dev/null +++ b/msg_viewer/static/src/lib/scripts/msg/streams/property/types/property-stream-entry.d.ts @@ -0,0 +1,7 @@ +import type { PropertyHeader } from "./property-header"; +import type { PropertyData } from "./property-data"; + +export interface PropertyStreamEntry { + header: PropertyHeader, + data: Map +} \ No newline at end of file diff --git a/msg_viewer/static/src/lib/scripts/msg/types/message.d.ts b/msg_viewer/static/src/lib/scripts/msg/types/message.d.ts new file mode 100644 index 0000000..3896b16 --- /dev/null +++ b/msg_viewer/static/src/lib/scripts/msg/types/message.d.ts @@ -0,0 +1,34 @@ +export interface Message { + file: CompoundFile, + content: MessageContent, + attachments: Attachment[], + recipients: Recipient[], +} + +export interface MessageContent { + date: Date, + subject: string, + senderName: string, + senderEmail: string, + body: string, + bodyHTML: string, + bodyRTF: Buffer, + headers: string, + toRecipients: string, + ccRecipients: string +} + +export interface Attachment { + extension: string, + fileName: string, + mimeType: string, + language: string, + displayName: string, + content: Buffer, + embeddedMsgObj: DirectoryEntry +} + +export interface Recipient { + name: string, + email: string, +} \ No newline at end of file diff --git a/msg_viewer/static/src/lib/scripts/utils/file-size-util.ts b/msg_viewer/static/src/lib/scripts/utils/file-size-util.ts new file mode 100644 index 0000000..5368e18 --- /dev/null +++ b/msg_viewer/static/src/lib/scripts/utils/file-size-util.ts @@ -0,0 +1,12 @@ +const SIZE = 1000; +const UNITS = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + +export function bytesWithUnits(bytes: number): string { + let unit = 0; + while(bytes >= SIZE){ + bytes = bytes / SIZE; + unit++; + } + + return `${bytes.toFixed(bytes < 10 && unit > 0 ? 1 : 0)} ${UNITS[unit]}`; +} diff --git a/msg_viewer/static/src/lib/scripts/utils/html-template-util.ts b/msg_viewer/static/src/lib/scripts/utils/html-template-util.ts new file mode 100644 index 0000000..77c0c35 --- /dev/null +++ b/msg_viewer/static/src/lib/scripts/utils/html-template-util.ts @@ -0,0 +1,20 @@ +export function createFragmentFromTemplate(html: string, obj: T): DocumentFragment { + const element = document.createElement("div"); + element.innerHTML = fillHTMLTemplate(html, obj); + + const fragment = document.createDocumentFragment(); + while(element.children.length > 0) { + fragment.appendChild(element.children[0]); + } + + return fragment; +} + +/** + * Fills an HTML template with object values in place of {{object.key}} + */ +export function fillHTMLTemplate(html: string, obj: T): string { + return html.replace(/{{(.*?)}}/g, (_m: string, key: string) => { + return obj[key.trim() as keyof typeof obj] as string; + }) +} \ No newline at end of file diff --git a/msg_viewer/static/src/lib/styles/root.css b/msg_viewer/static/src/lib/styles/root.css new file mode 100644 index 0000000..bb32cf6 --- /dev/null +++ b/msg_viewer/static/src/lib/styles/root.css @@ -0,0 +1,131 @@ +* { + box-sizing: border-box; + padding: 0; + margin: 0; +} + +:root { + font-size: 18px; +} + +/* Common utils */ +.color-text-2 { + color: rgb(168, 168, 168); +} + +.hidden { + display: none !important; +} + +.flex-b { + display: flex; + align-items: center; + justify-content: space-between; +} + +.flex-col { + display: flex; + flex-direction: column; + gap: 5px; +} + +.btn { + all: unset; + border-radius: 6px; + cursor: pointer; + background-color: #235b8d; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + padding: 16px; +} + +.btn:hover { + opacity: 0.8; +} + +.btn-2 { + display: flex; + gap: 10px; + padding: 10px 12px; + width: fit-content; + align-items: center; + color: white; + text-decoration: none; +} + +/* Per element */ +body { + background: rgb(36, 38, 43); + color: white; + font-family: Arial, Helvetica, sans-serif; + min-height: 100vh; +} + +#root { + display: grid; + grid-template-columns: 2fr 5fr; + grid-gap: 10px; + height: 100vh; + padding: 24px; +} + +#info { + align-items: center; + gap: 50px; +} + +.info-content { + flex: 1; + gap: 15px; + justify-content: center; + text-align: center; +} + + .info-content h1 { + font-size: 2.3rem; + } + + .info-content ul { + display: flex; + gap: 8px; + list-style: none; + flex-wrap: wrap; + justify-content: center; + } + + + .info-content ul li { + padding: 10px 14px; + border-radius: 10px; + background: #475369; + } + +.info-footer { + font-size: 0.8rem; + text-align: center; +} + +.footer-link { + color: rgb(102, 172, 248); +} + +#msg { + gap: 10px; + border: 3px solid white; + border-radius: 6px; + background: #333c4d; + width: 100%; + height: 100%; + overflow: auto; +} + +@media (max-width: 700px) { + #root { + grid-template-columns: 1fr; + grid-template-rows: 1fr 3fr; + } + + #msg { + min-height: 100%; + height: fit-content; + } +} diff --git a/msg_viewer/static/src/lib/styles/styles.css b/msg_viewer/static/src/lib/styles/styles.css new file mode 100644 index 0000000..c291526 --- /dev/null +++ b/msg_viewer/static/src/lib/styles/styles.css @@ -0,0 +1,2 @@ +@import "./lib/styles/root.css"; +@import "./lib/components/styles.css"; diff --git a/msg_viewer/static/src/xml/msg_viewer.xml b/msg_viewer/static/src/xml/msg_viewer.xml new file mode 100644 index 0000000..5bf31eb --- /dev/null +++ b/msg_viewer/static/src/xml/msg_viewer.xml @@ -0,0 +1,51 @@ + + + + +
+ + + +
+
+ +
+ +
+
+ +
+