UNPKG

@schukai/monster

Version:

Monster is a simple library for creating fast, robust and lightweight websites.

933 lines (829 loc) 29.8 kB
/** * Copyright © Volker Schukai and all contributing authors, {{copyRightYear}}. All rights reserved. * Node module: @schukai/monster * * This source code is licensed under the GNU Affero General Public License version 3 (AGPLv3). * The full text of the license can be found at: https://www.gnu.org/licenses/agpl-3.0.en.html * * For those who do not wish to adhere to the AGPLv3, a commercial license is available. * Acquiring a commercial license allows you to use this software without complying with the AGPLv3 terms. * For more information about purchasing a commercial license, please contact Volker Schukai. * * SPDX-License-Identifier: AGPL-3.0 */ import { instanceSymbol } from "../../../constants.mjs"; import { assembleMethodSymbol, CustomElement, registerCustomElement, updaterTransformerMethodsSymbol, } from "../../../dom/customelement.mjs"; import { sanitizeHtml } from "../../../dom/sanitize-html.mjs"; import { MessageStyleSheet } from "./stylesheet/message.mjs"; import { isArray, isObject } from "../../../types/is.mjs"; import { findTargetElementFromEvent } from "../../../dom/events.mjs"; import { getLocaleOfDocument } from "../../../dom/locale.mjs"; import "./html.mjs"; import "../../layout/tabs.mjs"; export { MessageContent }; /** * @private * @type {symbol} */ const containerElementSymbol = Symbol("containerElement"); /** * @privacy * @type {symbol} **/ const showPrivacyImagesSymbol = Symbol("showPrivvacyImages"); /** * @private * @type {symbol} */ const contentContainerElementSymbol = Symbol("contentContainerElement"); /** * @private * @type {symbol} */ const embeddedImageUrlsSymbol = Symbol("embeddedImageUrls"); /** * The MessageContent component is used to display arbitrary HTML content within its shadow DOM. * It provides options for sanitization to prevent XSS attacks. * * @copyright Volker Schukai * @summary An HTML content component that can display sanitized HTML. */ class MessageContent extends CustomElement { constructor() { super(); this[embeddedImageUrlsSymbol] = []; } /** * This method is called by the `instanceof` operator. * @return {symbol} */ static get [instanceSymbol]() { return Symbol.for( "@schukai/monster/components/content/viewer/message-content@@instance", ); } /** * To set the options via the HTML tag, the attribute `data-monster-options` must be used. * @see {@link https://monsterjs.org/en/doc/#configurate-a-monster-control} * * The individual configuration values can be found in the table. * * @property {Object} templates Template definitions * @property {string} templates.main Main template * @property {string} content The HTML string to be displayed. * @property {Object} features Features to enable or disable specific functionalities. * @property {boolean} features.sanitize Whether to sanitize the HTML content (removes scripts, etc.). Defaults to true. * @property {object} sanitize Sanitization options. * @property {function} sanitize.callback A callback function to sanitize the HTML content. Defaults to a built-in sanitizer. You can use libraries like DOMPurify for this purpose. * @property {Object} message The message object containing email details. * @property {Object} message.from The sender's information. * @property {string|null} message.from.name The sender's name. * @property {string|null} message.from.address The sender's email address. * @property {Object} message.to The recipient's information. * @property {string|null} message.to.name The recipient's name. * @property {string|null} message.to.address The recipient's email address. * @property {Array} message.cc An array of CC recipients. * @property {string|null} message.subject The subject of the email. * @property {string|null} message.date The date of the email, formatted as a string. * @property {string|null} message.messageID The unique identifier of the email message. * @property {Array} message.parts An array of parts of the email, which can include text, HTML, attachments, etc. * @property {Array} message.attachments An array of attachments processed from the email parts. * @property {Object} message.headers Additional headers of the email. */ get defaults() { return Object.assign({}, super.defaults, { templates: { main: getTemplate(), }, templateFormatter: { marker: { open: null, close: null, }, i18n: true, }, privacy: { visible: false, }, content: "", features: { sanitize: true, }, sanitize: { callback: sanitizeHtml.bind(this), }, labels: getTranslations(), message: { from: { name: null, address: null, }, to: { name: null, address: null, }, cc: [], subject: null, date: null, messageID: null, parts: [], attachments: [], // Added for processed attachments headers: [], }, }); } /** * Returns the updater transformer methods for this component. * @returns {{sanitizeHtml: ((function(*): (*))|*)}} */ [updaterTransformerMethodsSymbol]() { return { sanitizeHtml: (value) => { if (this.getOption("features.sanitize")) { return this.getOption("sanitize.callback")(value); } return value; }, }; } /** * Sets the content of the MessageContent component. * @param {Object} message The message object containing parts, headers, etc. * @returns {MessageContent} */ setMessage(message) { const self = this; if (!isObject(message)) { throw new Error("message must be an object"); } this[embeddedImageUrlsSymbol].forEach((url) => URL.revokeObjectURL(url)); this[embeddedImageUrlsSymbol] = []; this.setOption("message.from.name", message?.from?.name || null); this.setOption("message.from.address", message?.from?.address || null); this.setOption("message.to.name", message?.to?.name || null); this.setOption("message.to.address", message?.to?.address || null); const dateTime = message?.date ? new Date(message.date) : null; const localeDateTime = dateTime?.toLocaleString(navigator.language, { year: "numeric", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit", }); this.setOption("message.date", localeDateTime || null); this.setOption("message.cc", message?.cc || []); this.setOption("message.subject", message?.subject || null); this.setOption("message.messageID", message?.messageID || null); function escapeHTML(str) { return str .replace(/&/g, "&amp;") .replace(/</g, "&lt;") .replace(/>/g, "&gt;") .replace(/"/g, "&quot;") .replace(/'/g, "&#39;"); } const headers = []; let mainMimeType = null; for (const [key, value] of Object.entries(message?.headers || {})) { if (key && value) { let valueString = value; if (isArray(valueString)) { valueString = "<ul>"; for (const item of value) { const escapedItem = escapeHTML(item); valueString += `<li>${escapedItem}</li>`; } valueString += "</ul>"; } if (key.toLowerCase() === "content-type") { mainMimeType = valueString.split(";")[0].trim(); } headers.push({ key: key, value: valueString, }); } } this.setOption("message.headers", headers || []); let htmlContent = ""; let plainTextContent = ""; const attachments = []; const embeddedImages = {}; let maxDepth = 10; // Max recursion depth to prevent infinite loops const processParts = (parts, depth = 0) => { if (depth > maxDepth) { console.warn( `Max recursion depth exceeded for parts: ${JSON.stringify(parts)}`, ); return; } if (!parts || !Array.isArray(parts)) { return; } for (const part of parts) { try { if (part.parts && part.parts.length > 0) { processParts(part.parts, depth + 1); } else if ( part.dispositionType === "attachment" && part.contentType ) { part["index"] = attachments.length; // Füge Index hinzu, um die Reihenfolge zu verfolgen part["fileSize"] = part.content ? part.content.length : 0; // Dateigröße in Bytes part["humanReadableSize"] = part.content ? `${(part.content.length / 1024).toFixed(2)} KB` : "0 KB"; // Menschlich lesbare Größe attachments.push(part); } else if ( part.contentType && part.contentType.toLowerCase().startsWith("text/html") ) { htmlContent = part.content; } else if ( part.contentType && part.contentType.toLowerCase().startsWith("text/plain") ) { if (!htmlContent) { plainTextContent = part.content; } } else if ( // part.dispositionType === "inline" && part.contentType && part.contentType.toLowerCase().startsWith("image/") ) { const cid = part?.["contentId"] || (part.filename ? part.filename.split(".").slice(0, -1).join(".") : null); if (cid) { embeddedImages[cid] = part; } else { console.warn( "Inline image part found without Content-ID or filename:", part, ); } } } catch (e) { console.error("Error processing part:", part, e); } } }; if (message?.parts) { processParts(message.parts); } if (!htmlContent && plainTextContent) { htmlContent = plainTextContent.replace(/\n/g, "<br>"); } for (const cid in embeddedImages) { const imagePart = embeddedImages[cid]; if (imagePart.content && imagePart.contentType) { try { const base64ImageContent = imagePart.content.replace(/\s/g, ""); const imageContentType = imagePart.contentType; let cleanCid = imagePart.contentId; if (cleanCid) { cleanCid = cleanCid.trim(); cleanCid = cleanCid.replace(/[\u0000-\u001F\u007F-\u009F]/g, ""); // Steuerzeichen entfernen } else { cleanCid = imagePart.filename ? imagePart.filename.split(".").slice(0, -1).join(".") : null; if (!cleanCid) { console.warn( "Content-ID or filename not found for an image part. Cannot replace CID in HTML.", ); continue; // Überspringe dieses Bild, wenn CID fehlt } } const decodedContent = atob(base64ImageContent); const uint8Array = new Uint8Array(decodedContent.length); for (let i = 0; i < decodedContent.length; i++) { uint8Array[i] = decodedContent.charCodeAt(i); } const blob = new Blob([uint8Array], { type: imageContentType }); const objectUrl = URL.createObjectURL(blob); this[embeddedImageUrlsSymbol].push(objectUrl); // Speichern zur späteren Widerrufung embeddedImages[cid].objectUrl = objectUrl; } catch (e) { console.error( `Error processing embedded image with CID '${cid}':`, e, ); } } } htmlContent = replaceCidImages.call(this, htmlContent, embeddedImages); this[contentContainerElementSymbol].setOption("content", htmlContent); this.setOption("message.attachments", attachments); this.setOption("message.parts", message?.parts || []); return this; } /** * Handles the click event for an attachment download button. * @param {Event} event * @param {Object} part The attachment part data. */ onDownloadAttachmentClick(event, part) { event.preventDefault(); if (part.content && part.filename && part.contentType) { try { let content = part.content || ""; const contentTransferEncoding = ( part?.contentTransferEncoding || "" ).toLowerCase(); switch (contentTransferEncoding) { case "base64": content = atob(content); case "quoted-printable": content = decodeQuotedPrintable(content); break; default: } const decodedContent = content; const uint8Array = new Uint8Array(decodedContent.length); for (let i = 0; i < decodedContent.length; i++) { uint8Array[i] = decodedContent.charCodeAt(i); } const blob = new Blob([uint8Array], { type: part.contentType }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = part.filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } catch (e) { console.error("Error downloading attachment:", e); alert( "Could not download file. Content might not be base64 or is corrupted.", ); } } else { alert("Attachment content not available for download."); } } /** * @return {string} */ static getTag() { return "monster-message-content"; } /** * @return {MessageContent} */ [assembleMethodSymbol]() { super[assembleMethodSymbol](); initControlReferences.call(this); initEventHandler.call(this); } /** * @return {Array} */ static getCSSStyleSheet() { return [MessageStyleSheet]; } /** * Cleans up any resources when the element is removed from the DOM. * Note: This method relies on the CustomElement base class calling it. * If CustomElement does not have a disconnectedCallback equivalent, * manual cleanup or a different strategy will be needed. */ disconnectedCallback() { super.disconnectedCallback?.(); // Call super's disconnectedCallback if it exists this[embeddedImageUrlsSymbol].forEach((url) => URL.revokeObjectURL(url)); this[embeddedImageUrlsSymbol] = []; } } /** * Replaces 'cid:' images in the HTML content with actual URLs. * @private */ function replaceCidImages(htmlContent, replacements) { const objectURLEmptyGif = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"; const parser = new DOMParser(); const doc = parser.parseFromString(htmlContent, "text/html"); const images = doc.querySelectorAll("img"); let hasPrivacyImage = false; images.forEach((img) => { const src = img.getAttribute("src"); if (src && src.toLowerCase().startsWith("cid:")) { const cid = src.toLowerCase().substring(4); if (replacements[cid]) { img.setAttribute("src", replacements[cid].objectUrl); } return; } else if (src && src.toLowerCase().startsWith("http")) { const urlImage = new URL(src, document.location.href); if (urlImage.origin !== document.location.origin) { img.setAttribute("src", objectURLEmptyGif); img.classList.add("privacyImage"); img.setAttribute("data-monster-privacy", "true"); img.setAttribute("data-monster-privacy_origin-url", src); hasPrivacyImage = true; // If the src is an HTTP URL, we can keep it as is img.setAttribute( "title", this.getOption("labels.privacyImageTitle") || "This image is from an external source and may not be safe to display.", ); } // return; } }); if (hasPrivacyImage) { this.setOption("privacy.visible", true); // Show the privacy text if any images are marked as privacy } // Serialize the modified document back to HTML const serializer = new XMLSerializer(); return serializer.serializeToString(doc); } /** * @private * @return {MessageContent} */ function initControlReferences() { if (!this.shadowRoot) { throw new Error("no shadow-root is defined"); } this[showPrivacyImagesSymbol] = this.shadowRoot.querySelector( "[data-monster-role=show-privacy-images]", ); this[containerElementSymbol] = this.shadowRoot.querySelector( "[data-monster-role=container]", ); this[contentContainerElementSymbol] = this.shadowRoot.querySelector( "[data-monster-role=content-container]", ); return this; } function getTranslations() { const locale = getLocaleOfDocument(); switch (locale.language) { case "de": // German return { content: "Inhalt", headers: "Kopfzeilen", privacyText: "Diese Nachricht kann externe Inhalte enthalten, die nicht sicher angezeigt werden können.", showImages: "Bilder anzeigen", privacyImageTitle: "Dieses Bild stammt von einer externen Quelle und kann unsicher sein.", }; case "es": // Spanish return { content: "Contenido", headers: "Encabezados", privacyText: "Este mensaje puede contener contenido externo que no es seguro mostrar.", showImages: "Mostrar imágenes", privacyImageTitle: "Esta imagen proviene de una fuente externa y puede no ser segura para mostrar.", }; case "hi": // Hindi return { content: "सामग्री", headers: "शीर्षक", privacyText: "इस संदेश में बाहरी सामग्री हो सकती है जिसे सुरक्षित रूप से प्रदर्शित नहीं किया जा सकता।", showImages: "छवियाँ दिखाएँ", privacyImageTitle: "यह छवि एक बाहरी स्रोत से है और इसे प्रदर्शित करना सुरक्षित नहीं हो सकता।", }; case "bn": // Bengali return { content: "বিষয়বস্তু", headers: "শিরোনাম", privacyText: "এই বার্তাটিতে এমন বাহ্যিক বিষয়বস্তু থাকতে পারে যা নিরাপদে প্রদর্শন করা যায় না।", showImages: "ছবি দেখান", privacyImageTitle: "এই চিত্রটি একটি বাহ্যিক উৎস থেকে এসেছে এবং এটি প্রদর্শন করা নিরাপদ নাও হতে পারে।", }; case "pt": // Portuguese return { content: "Conteúdo", headers: "Cabeçalhos", privacyText: "Esta mensagem pode conter conteúdo externo que não pode ser exibido com segurança.", showImages: "Mostrar imagens", }; case "ru": // Russian return { content: "Содержание", headers: "Заголовки", privacyText: "Это сообщение может содержать внешнее содержимое, которое небезопасно отображать.", showImages: "Показать изображения", privacyImageTitle: "Это изображение из внешнего источника и может быть небезопасным для отображения.", }; case "ja": // Japanese return { content: "コンテンツ", headers: "ヘッダー", privacyText: "このメッセージには安全に表示できない外部コンテンツが含まれている可能性があります。", showImages: "画像を表示", privacyImageTitle: "この画像は外部ソースからのものであり、安全に表示できない可能性があります。", }; case "pa": // Western Punjabi return { content: "ਸਮੱਗਰੀ", headers: "ਸਿਰਲੇਖ", privacyText: "ਇਸ ਸੁਨੇਹੇ ਵਿੱਚ ਬਾਹਰੀ ਸਮੱਗਰੀ ਹੋ ਸਕਦੀ ਹੈ ਜਿਸ ਨੂੰ ਸੁਰੱਖਿਅਤ ਤਰੀਕੇ ਨਾਲ ਨਹੀਂ ਦਿਖਾਇਆ ਜਾ ਸਕਦਾ।", showImages: "ਤਸਵੀਰਾਂ ਵੇਖੋ", privacyImageTitle: "ਇਹ ਤਸਵੀਰ ਇੱਕ ਬਾਹਰੀ ਸਰੋਤ ਤੋਂ ਹੈ ਅਤੇ ਇਸ ਨੂੰ ਦਿਖਾਉਣਾ ਸੁਰੱਖਿਅਤ ਨਹੀਂ ਹੋ ਸਕਦਾ।", }; case "mr": // Marathi return { content: "सामग्री", headers: "शीर्षके", privacyText: "या संदेशात सुरक्षितपणे दाखवता न येणारी बाह्य सामग्री असू शकते.", showImages: "प्रतिमा दाखवा", privacyImageTitle: "ही प्रतिमा बाह्य स्रोताकडून आहे आणि ती दाखवणे सुरक्षित नसू शकते.", }; case "fr": // French return { content: "Contenu", headers: "En-têtes", privacyText: "Ce message peut contenir du contenu externe qui ne peut pas être affiché de façon sécurisée.", showImages: "Afficher les images", privacyImageTitle: "Cette image provient d'une source externe et peut ne pas être sécurisée à afficher.", }; case "it": // Italian return { content: "Contenuto", headers: "Intestazioni", privacyText: "Questo messaggio può contenere contenuti esterni che non possono essere visualizzati in modo sicuro.", showImages: "Mostra immagini", privacyImageTitle: "Questa immagine proviene da una fonte esterna e potrebbe non essere sicura da visualizzare.", }; case "nl": // Dutch return { content: "Inhoud", headers: "Headers", privacyText: "Dit bericht kan externe inhoud bevatten die niet veilig kan worden weergegeven.", showImages: "Afbeeldingen tonen", privacyImageTitle: "Deze afbeelding komt van een externe bron en is mogelijk niet veilig om weer te geven.", }; case "sv": // Swedish return { content: "Innehåll", headers: "Rubriker", privacyText: "Detta meddelande kan innehålla extern information som inte kan visas säkert.", showImages: "Visa bilder", privacyImageTitle: "Denna bild kommer från en extern källa och kan vara osäker att visa.", }; case "pl": // Polish return { content: "Zawartość", headers: "Nagłówki", privacyText: "Ta wiadomość może zawierać zewnętrzne treści, których nie można bezpiecznie wyświetlić.", showImages: "Pokaż obrazy", privacyImageTitle: "Ten obraz pochodzi z zewnętrznego źródła i może nie być bezpieczny do wyświetlenia.", }; case "da": // Danish return { content: "Indhold", headers: "Overskrifter", privacyText: "Denne besked kan indeholde eksternt indhold, der ikke kan vises sikkert.", showImages: "Vis billeder", privacyImageTitle: "Dette billede kommer fra en ekstern kilde og kan være usikkert at vise.", }; case "no": // Norwegian return { content: "Innhold", headers: "Overskrifter", privacyText: "Denne meldingen kan inneholde eksternt innhold som ikke kan vises sikkert.", showImages: "Vis bilder", privacyImageTitle: "Dette bildet kommer fra en ekstern kilde og kan være usikkert å vise.", }; case "cs": // Czech return { content: "Obsah", headers: "Hlavičky", privacyText: "Tato zpráva může obsahovat externí obsah, který nelze bezpečně zobrazit.", showImages: "Zobrazit obrázky", privacyImageTitle: "Tento obrázek pochází z externího zdroje a nemusí být bezpečný k zobrazení.", }; default: // English fallback return { content: "Content", headers: "Headers", privacyText: "This message may contain external content that cannot be displayed securely.", showImages: "Show images", privacyImageTitle: "This image is from an external source and may not be safe to display.", }; } } function decodeQuotedPrintable(input) { // Soft line breaks (= am Zeilenende) entfernen let str = input.replace(/=\r?\n/g, ""); // =XX in das entsprechende Byte umwandeln const bytes = str.replace(/=([A-Fa-f0-9]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)), ); // Als UTF-8 interpretieren return decodeURIComponent(escape(bytes)); } /** * @private * @param index * @param attachments */ function downloadAttachmentByIndex(index, attachments) { const part = attachments[index]; if (!part) { console.error(`Attachment mit Index ${index} nicht gefunden.`); return; } const { filename, contentType, content, contentTransferEncoding } = part; try { let decodedContent; if ( contentTransferEncoding && contentTransferEncoding.toLowerCase() === "base64" ) { decodedContent = atob(content); } else if ( contentTransferEncoding && contentTransferEncoding.toLowerCase() === "quoted-printable" ) { decodedContent = decodeQuotedPrintable(content); } const len = decodedContent.length; const bytes = new Uint8Array(len); for (let i = 0; i < len; i++) { bytes[i] = decodedContent.charCodeAt(i); } const blob = new Blob([bytes], { type: contentType }); const dataUrl = URL.createObjectURL(blob); const a = document.createElement("a"); a.style.display = "none"; a.href = dataUrl; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(dataUrl); } catch (e) { console.error("Error downloading attachment:", e); } } /** * Initializes the event handler for the MessageContent component. * @private * @returns {initEventHandler} */ function initEventHandler() { this[showPrivacyImagesSymbol].addEventListener("click", (event) => { event.preventDefault(); const currentContent = this[contentContainerElementSymbol].getOption("content"); if (!currentContent) { console.warn("No content available to show privacy images."); return; } const domParser = new DOMParser(); const doc = domParser.parseFromString(currentContent, "text/html"); doc.querySelectorAll("img[data-monster-privacy=true]").forEach((img) => { const originalUrl = img.getAttribute("data-monster-privacy_origin-url"); if (originalUrl) { img.setAttribute("src", originalUrl); img.removeAttribute("data-monster-privacy"); img.removeAttribute("data-monster-privacy_origin-url"); img.removeAttribute("title"); img.classList.remove("privacyImage"); this.setOption("privacy.visible", false); // Hide the privacy text } }); this[contentContainerElementSymbol].setOption( "content", doc.documentElement.outerHTML, ); }); this[containerElementSymbol].addEventListener("click", (event) => { const card = findTargetElementFromEvent( event, "data-monster-role", "attachment", ); if (card) { const index = card.getAttribute("data-monster-index"); const attachments = this.getOption("message.attachments"); if ( index !== null && index !== undefined && attachments && Array.isArray(attachments) ) { const parsedIndex = parseInt(index, 10); if ( !isNaN(parsedIndex) && parsedIndex >= 0 && parsedIndex < attachments.length ) { downloadAttachmentByIndex(parsedIndex, attachments); } else { this.dispatchEvent( new CustomEvent("error", { detail: { message: `Invalid attachment index: ${index}. Must be a number between 0 and ${attachments.length - 1}.`, }, }), ); } } } }); return this; } /** * @private * @return {string} */ function getTemplate() { // language=HTML return ` <template id="attachments"> <div class="attachments"> <div class="attachment-card" data-monster-role="attachment" data-monster-attributes="data-monster-index path:attachments.index"> <div class="attachment-icon downloadIcon" title="Download"></div> <div class="attachment-info"> <strong class="attachment-filename" data-monster-attributes="title path:attachments.filename | default:—" data-monster-replace="path:attachments.filename | default:—"></strong> <span class="attachment-size" data-monster-replace="path:attachments.humanReadableSize | default:—"></span> </div> </div> </div> </template> <template id="headers"> <div data-monster-replace="path:headers.key"> </div> <div class="header-value" data-monster-replace="path:headers.value | default:—"></div> </template> <div data-monster-role="container" part="container"> <div class="emailContainer"> <div class="emailHeader"> <div><strong><span data-monster-replace="path:message.from.name | default:—"></span> <span class="reduced" data-monster-replace="path:message.from.address"></span></strong></div> <div class="emailDate" data-monster-replace="path:message.date | default:—"></div> <div class="emailSubject" data-monster-replace="path:message.subject | default:—"></div> <div data-monster-attributes="class path:privacy.visible | ?::hidden"> <p data-monster-replace="path:labels.privacyText | default: "></p> <monster-button data-monster-role="show-privacy-images" data-monster-replace="path:labels.showImages"></monster-button> </div> </div> <monster-tabs> <div data-monster-button-label="i18n{content}"> <monster-html-content class="emailContent" data-monster-role="content-container"> </monster-html-content> <div data-monster-role="attachments" data-monster-insert="attachments path:message.attachments"></div> </div> <div data-monster-button-label="i18n{headers}"> <div data-monster-insert="headers path:message.headers" data-monster-role="headers-container"></div> </div> </monster-tabs> </div> </div> `; } registerCustomElement(MessageContent);