diff --git a/dev/Model/Message.js b/dev/Model/Message.js index 79b4149cd..23066f687 100644 --- a/dev/Model/Message.js +++ b/dev/Model/Message.js @@ -22,6 +22,9 @@ import { LanguageStore } from 'Stores/Language'; import Remote from 'Remote/User/Fetch'; +import { showScreenPopup } from 'Knoin/Knoin'; +import { MessagePopupView } from 'View/Popup/Message'; + const msgHtml = msg => cleanHtml(msg.html(), msg.attachments(), '#rl-msg-' + msg.hash), @@ -347,34 +350,38 @@ export class MessageModel extends AbstractModel { * @param {boolean=} print = false */ popupMessage(print) { - const - timeStampInUTC = this.dateTimestamp() || 0, - ccLine = this.cc.toString(), - bccLine = this.bcc.toString(), - m = 0 < timeStampInUTC ? new Date(timeStampInUTC * 1000) : null, - win = open('', 'sm-msg-'+this.requestHash - ,SettingsUserStore.messageNewWindow() ? 'innerWidth=' + elementById('V-MailMessageView').clientWidth : '' - ), - sdoc = win.document, - subject = encodeHtml(this.subject()), - mode = this.isHtml() ? 'div' : 'pre', - to = `
${encodeHtml(i18n('GLOBAL/TO'))}: ${encodeHtml(this.to)}
` - + (ccLine ? `
${encodeHtml(i18n('GLOBAL/CC'))}: ${encodeHtml(ccLine)}
` : '') - + (bccLine ? `
${encodeHtml(i18n('GLOBAL/BCC'))}: ${encodeHtml(bccLine)}
` : ''), - style = getComputedStyle(doc.querySelector('.messageView')), - prop = property => style.getPropertyValue(property); - let attachments = ''; - this.attachments.forEach(attachment => { - attachments += `${attachment.fileName}`; - }); - sdoc.write(PreviewHTML - .replace('', '<title>'+subject) - // eslint-disable-next-line max-len - .replace('<body>', `<body style="background-color:${prop('background-color')};color:${prop('color')}"><header><h1>${subject}</h1><time>${encodeHtml(m ? m.format('LLL',0,LanguageStore.hourCycle()) : '')}</time><div>${encodeHtml(this.from)}</div>${to}</header><${mode}>${this.bodyAsHTML()}</${mode}>`) - .replace('</body>', `<div id="attachments">${attachments}</div></body>`) - ); - sdoc.close(); - (true === print) && setTimeout(() => win.print(), 100); + if (print) { + const + timeStampInUTC = this.dateTimestamp() || 0, + ccLine = this.cc.toString(), + bccLine = this.bcc.toString(), + m = 0 < timeStampInUTC ? new Date(timeStampInUTC * 1000) : null, + win = open('', 'sm-msg-'+this.requestHash + ,SettingsUserStore.messageNewWindow() ? 'innerWidth=' + elementById('V-MailMessageView').clientWidth : '' + ), + sdoc = win.document, + subject = encodeHtml(this.subject()), + mode = this.isHtml() ? 'div' : 'pre', + to = `<div>${encodeHtml(i18n('GLOBAL/TO'))}: ${encodeHtml(this.to)}</div>` + + (ccLine ? `<div>${encodeHtml(i18n('GLOBAL/CC'))}: ${encodeHtml(ccLine)}</div>` : '') + + (bccLine ? `<div>${encodeHtml(i18n('GLOBAL/BCC'))}: ${encodeHtml(bccLine)}</div>` : ''), + style = getComputedStyle(doc.querySelector('.messageView')), + prop = property => style.getPropertyValue(property); + let attachments = ''; + this.attachments.forEach(attachment => { + attachments += `<a href="${attachment.linkDownload()}">${attachment.fileName}</a>`; + }); + sdoc.write(PreviewHTML + .replace('<title>', '<title>'+subject) + // eslint-disable-next-line max-len + .replace('<body>', `<body style="background-color:${prop('background-color')};color:${prop('color')}"><header><h1>${subject}</h1><time>${encodeHtml(m ? m.format('LLL',0,LanguageStore.hourCycle()) : '')}</time><div>${encodeHtml(this.from)}</div>${to}</header><${mode}>${this.bodyAsHTML()}</${mode}>`) + .replace('</body>', `<div id="attachments">${attachments}</div></body>`) + ); + sdoc.close(); + (true === print) && setTimeout(() => win.print(), 100); + } else { + showScreenPopup(MessagePopupView, [this]); + } } printMessage() { diff --git a/dev/View/Popup/Message.js b/dev/View/Popup/Message.js new file mode 100644 index 000000000..030d632db --- /dev/null +++ b/dev/View/Popup/Message.js @@ -0,0 +1,453 @@ +import ko from 'ko'; +import { addObservablesTo, addComputablesTo, addSubscribablesTo } from 'External/ko'; + +import { ScopeMessageList, ScopeMessageView } from 'Common/Enums'; + +import { + ComposeType, + ClientSideKeyNameMessageHeaderFullInfo, + ClientSideKeyNameMessageAttachmentControls +} from 'Common/EnumsUser'; +import { RFC822 } from 'Common/File'; +import { + keyScopeReal, + Settings, + SettingsCapa, + stopEvent, + addShortcut, + registerShortcut +} from 'Common/Globals'; + +import { arrayLength } from 'Common/Utils'; +import { download, downloadZip, mailToHelper, showMessageComposer, moveAction } from 'Common/UtilsUser'; +import { isFullscreen, exitFullscreen, toggleFullscreen } from 'Common/Fullscreen'; + +import { SMAudio } from 'Common/Audio'; + +import { i18n } from 'Common/Translator'; + +import { AppUserStore } from 'Stores/User/App'; +import { SettingsUserStore } from 'Stores/User/Settings'; +import { AccountUserStore } from 'Stores/User/Account'; +import { FolderUserStore, isAllowedKeyword } from 'Stores/User/Folder'; + +import * as Local from 'Storage/Client'; + +import Remote from 'Remote/User/Fetch'; + +import { AbstractViewPopup } from 'Knoin/AbstractViews'; + +import { PgpUserStore } from 'Stores/User/Pgp'; + +import { MimeToMessage } from 'Mime/Utils'; + +import { MessageModel } from 'Model/Message'; + +import { showScreenPopup } from 'Knoin/Knoin'; +import { OpenPgpImportPopupView } from 'View/Popup/OpenPgpImport'; +import { GnuPGUserStore } from 'Stores/User/GnuPG'; +import { OpenPGPUserStore } from 'Stores/User/OpenPGP'; + +const + fetchRaw = url => rl.fetch(url).then(response => response.ok && response.text()); + +export class MessagePopupView extends AbstractViewPopup { + constructor() { + super('Message'); + + const + /** + * @param {Function} fExecute + * @param {Function} fCanExecute = true + * @returns {Function} + */ + createCommand = (fExecute, fCanExecute) => { + let fResult = () => { + fCanExecute() && fExecute.call(null); + return false; + }; + fResult.canExecute = fCanExecute; + return fResult; + }, + + createCommandReplyHelper = type => + createCommand(() => this.replyOrforward(type), ()=>1); + + this.msgDefaultAction = SettingsUserStore.msgDefaultAction; + this.simpleAttachmentsList = SettingsUserStore.simpleAttachmentsList; + + addObservablesTo(this, { + message: null, + showAttachmentControls: !!Local.get(ClientSideKeyNameMessageAttachmentControls), + downloadAsZipLoading: false, + showFullInfo: '1' === Local.get(ClientSideKeyNameMessageHeaderFullInfo), + // viewer + viewFromShort: '', + dkimData: ['none', '', ''] + }); + + this.moveAction = moveAction; + + const attachmentsActions = Settings.app('attachmentsActions'); + this.attachmentsActions = ko.observableArray(arrayLength(attachmentsActions) ? attachmentsActions : []); + + this.fullScreenMode = isFullscreen; + this.toggleFullScreen = toggleFullscreen; + + this.downloadAsZipError = ko.observable(false).extend({ falseTimeout: 7000 }); + + this.messageDomFocused = ko.observable(false).extend({ rateLimit: 0 }); + + // viewer + this.viewHash = ''; + + addComputablesTo(this, { + allowAttachmentControls: () => arrayLength(attachmentsActions) && SettingsCapa('AttachmentsActions'), + + downloadAsZipAllowed: () => this.attachmentsActions.includes('zip') + && (this.message()?.attachments || []) + .filter(item => item?.download /*&& !item?.isLinked()*/ && item?.checked()) + .length, + + tagsAllowed: () => FolderUserStore.currentFolder()?.tagsAllowed(), + + tagsToHTML: () => this.message()?.flags().map(value => + isAllowedKeyword(value) + ? '<span class="focused msgflag-'+value+'">' + i18n('MESSAGE_TAGS/'+value,0,value) + '</span>' + : '' + ).join(' '), + + listAttachments: () => this.message()?.attachments() + .filter(item => SettingsUserStore.listInlineAttachments() || !item.isLinked()), + hasAttachments: () => this.listAttachments()?.length, + + viewDkimIcon: () => 'none' !== this.dkimData()[0], + + dkimIconClass:() => { + switch (this.dkimData()[0]) { + case 'none': + return ''; + case 'pass': + return 'icon-ok iconcolor-green'; // ✔️ + default: + return 'icon-cross iconcolor-red'; // ✖ ❌ + } + }, + + dkimTitle:() => { + const dkim = this.dkimData(); + return dkim[0] ? dkim[2] || 'DKIM: ' + dkim[0] : ''; + }, + + showWhitelistOptions: () => 'match' === SettingsUserStore.viewImages(), + + firstUnsubsribeLink: () => this.message()?.unsubsribeLinks()[0] || '', + + pgpSupported: () => this.message() && PgpUserStore.isSupported() + }); + + addSubscribablesTo(this, { + message: message => { + if (message) { + this.viewHash = message.hash; + // TODO: make first param a user setting #683 + this.viewFromShort(message.from.toString(false, true)); + this.dkimData(message.dkim[0] || ['none', '', '']); + } else { + this.viewHash = ''; + } + }, + + showFullInfo: value => Local.set(ClientSideKeyNameMessageHeaderFullInfo, value ? '1' : '0') + }); + + // commands + this.replyCommand = createCommandReplyHelper(ComposeType.Reply); + this.replyAllCommand = createCommandReplyHelper(ComposeType.ReplyAll); + this.forwardCommand = createCommandReplyHelper(ComposeType.Forward); + this.forwardAsAttachmentCommand = createCommandReplyHelper(ComposeType.ForwardAsAttachment); + this.editAsNewCommand = createCommandReplyHelper(ComposeType.EditAsNew); + } + + toggleFullInfo() { + this.showFullInfo(!this.showFullInfo()); + } + + /** + * @param {string} sType + * @returns {void} + */ + replyOrforward(sType) { + showMessageComposer([sType, this.message()]); + } + + onBuild(dom) { + const eqs = (ev, s) => ev.target.closestWithin(s, dom); + dom.addEventListener('click', event => { + let el = eqs(event, 'a'); + if (el && 0 === event.button && mailToHelper(el.href)) { + stopEvent(event); + return; + } + + el = eqs(event, '.attachmentsPlace .showPreview'); + if (el) { + const attachment = ko.dataFor(el), url = attachment?.linkDownload(); +// if (url && FileType.Eml === attachment.fileType) { + if (url && RFC822 == attachment.mimeType) { + stopEvent(event); + fetchRaw(url).then(text => { + const oMessage = new MessageModel(); + MimeToMessage(text, oMessage); + // cleanHTML + oMessage.popupMessage(); + }); + } + return; + } + + el = eqs(event, '.attachmentsPlace .showPreplay'); + if (el) { + stopEvent(event); + const attachment = ko.dataFor(el); + if (attachment && SMAudio.supported) { + switch (true) { + case SMAudio.supportedMp3 && attachment.isMp3(): + SMAudio.playMp3(attachment.linkDownload(), attachment.fileName); + break; + case SMAudio.supportedOgg && attachment.isOgg(): + SMAudio.playOgg(attachment.linkDownload(), attachment.fileName); + break; + case SMAudio.supportedWav && attachment.isWav(): + SMAudio.playWav(attachment.linkDownload(), attachment.fileName); + break; + // no default + } + } + return; + } + + el = eqs(event, '.attachmentItem'); + if (el) { + const attachment = ko.dataFor(el), url = attachment?.linkDownload(); + if (url) { + if ('application/pgp-keys' == attachment.mimeType + && (OpenPGPUserStore.isSupported() || GnuPGUserStore.isSupported())) { + fetchRaw(url).then(text => + showScreenPopup(OpenPgpImportPopupView, [text]) + ); + } else { + download(url, attachment.fileName); + } + } + } + }); + + keyScopeReal.subscribe(value => this.messageDomFocused(ScopeMessageView === value)); + + // initShortcuts + + // exit fullscreen, back + addShortcut('escape', '', ScopeMessageView, () => { + if (!this.viewModelDom.hidden && this.message()) { + const preview = SettingsUserStore.usePreviewPane(); + if (isFullscreen()) { + exitFullscreen(); + if (preview) { + AppUserStore.focusedState(ScopeMessageList); + } + } else if (!preview) { + this.message(null); + } else { + AppUserStore.focusedState(ScopeMessageList); + } + + return false; + } + }); + + // fullscreen + addShortcut('enter,open', '', ScopeMessageView, () => { + isFullscreen() || toggleFullscreen(); + return false; + }); + + // reply + registerShortcut('r,mailreply', '', [ScopeMessageList, ScopeMessageView], () => { + if (this.message()) { + this.replyCommand(); + return false; + } + return true; + }); + + // replyAll + registerShortcut('a', '', [ScopeMessageList, ScopeMessageView], () => { + if (this.message()) { + this.replyAllCommand(); + return false; + } + }); + registerShortcut('mailreply', 'shift', [ScopeMessageList, ScopeMessageView], () => { + if (this.message()) { + this.replyAllCommand(); + return false; + } + }); + + // forward + registerShortcut('f,mailforward', '', [ScopeMessageList, ScopeMessageView], () => { + if (this.message()) { + this.forwardCommand(); + return false; + } + }); + + // message information + registerShortcut('i', 'meta', [ScopeMessageList, ScopeMessageView], () => { + this.message() && this.toggleFullInfo(); + return false; + }); + + // toggle message blockquotes + registerShortcut('b', '', [ScopeMessageList, ScopeMessageView], () => { + const message = this.message(); + if (message?.body) { + message.body.querySelectorAll('details').forEach(node => node.open = !node.open); + return false; + } + }); + + this.body = dom.querySelector('.bodyText'); + } + + onShow(message) { + this.message(message); + message.body = this.body; + message.viewBody(message.html()); + } + + toggleAttachmentControls() { + const b = !this.showAttachmentControls(); + this.showAttachmentControls(b); + Local.set(ClientSideKeyNameMessageAttachmentControls, b); + } + + downloadAsZip() { + const hashes = (this.message()?.attachments || []) + .map(item => item?.checked() /*&& !item?.isLinked()*/ ? item.download : '') + .filter(v => v); + downloadZip( + this.message().subject(), + hashes, + () => this.downloadAsZipError(true), + this.downloadAsZipLoading + ); + } + + /** + * @param {MessageModel} oMessage + * @returns {void} + */ + showImages() { + this.message().showExternalImages(); + } + + whitelistText(txt) { + let value = (SettingsUserStore.viewImagesWhitelist().trim() + '\n' + txt).trim(); +/* + if ('pass' === this.message().spf[0]?.[0]) value += '+spf'; + if ('pass' === this.message().dkim[0]?.[0]) value += '+dkim'; + if ('pass' === this.message().dmarc[0]?.[0]) value += '+dmarc'; +*/ + SettingsUserStore.viewImagesWhitelist(value); + Remote.saveSetting('ViewImagesWhitelist', value); + this.message().showExternalImages(1); + } + + /** + * @param {MessageModel} oMessage + * @returns {void} + */ + readReceipt() { + let oMessage = this.message() + if (oMessage.readReceipt()) { + Remote.request('SendReadReceiptMessage', iError => { + if (!iError) { + oMessage.flags.push('$mdnsent'); +// oMessage.flags.valueHasMutated(); + } + }, { + messageFolder: oMessage.folder, + messageUid: oMessage.uid, + readReceipt: oMessage.readReceipt(), + subject: i18n('READ_RECEIPT/SUBJECT', { SUBJECT: oMessage.subject() }), + plain: i18n('READ_RECEIPT/BODY', { 'READ-RECEIPT': AccountUserStore.email() }) + }); + } + } + + newTag() { + let message = this.message(); + if (message) { + let keyword = prompt(i18n('MESSAGE/NEW_TAG'), '')?.replace(/[\s\\]+/g, ''); + if (keyword.length && isAllowedKeyword(keyword)) { + message.toggleTag(keyword); + FolderUserStore.currentFolder().permanentFlags.push(keyword); + } + } + } + + pgpDecrypt() { + const oMessage = this.message(); + PgpUserStore.decrypt(oMessage).then(result => { + if (result) { + oMessage.pgpDecrypted(true); + if (result.data) { + MimeToMessage(result.data, oMessage); + oMessage.html() ? oMessage.viewHtml() : oMessage.viewPlain(); + if (result.signatures?.length) { + oMessage.pgpSigned(true); + oMessage.pgpVerified({ + signatures: result.signatures, + success: !!result.signatures.length + }); + } + } + } else { + // TODO: translate + alert('Decryption failed, canceled or not possible'); + } + }) + .catch(e => console.error(e)); + } + + pgpVerify(/*self, event*/) { + const oMessage = this.message()/*, ctrl = event.target.closest('.openpgp-control')*/; + PgpUserStore.verify(oMessage).then(result => { + if (result) { + oMessage.pgpVerified(result); + } else { + alert('Verification failed or no valid public key found'); + } +/* + if (result?.success) { + i18n('OPENPGP/GOOD_SIGNATURE', { + USER: validKey.user + ' (' + validKey.id + ')' + }); + message.getText() + } else { + const keyIds = arrayLength(signingKeyIds) ? signingKeyIds : null, + additional = keyIds + ? keyIds.map(item => item?.toHex?.()).filter(v => v).join(', ') + : ''; + + i18n('OPENPGP/ERROR', { + ERROR: 'message' + }) + (additional ? ' (' + additional + ')' : ''); + } +*/ + }); + } + +} diff --git a/snappymail/v/0.0.0/app/templates/Views/User/PopupsMessage.html b/snappymail/v/0.0.0/app/templates/Views/User/PopupsMessage.html new file mode 100644 index 000000000..a67f6c298 --- /dev/null +++ b/snappymail/v/0.0.0/app/templates/Views/User/PopupsMessage.html @@ -0,0 +1,220 @@ +<header class="g-ui-user-select-none"> + <a href="#" class="close" data-bind="click: close">×</a> + <h3 data-bind="text: message()?.subject"></h3> +</header> +<div class="modal-body b-message" data-bind="i18nUpdate: message, css: message()?.lineAsCss(0)"> + <div class="message-fixed-button-toolbar"> + <div class="btn-group" style="margin-right: -8px; display: inline;"> + <span> + <a class="btn btn-thin btn-transparent buttonReply fontastic" + data-bind="visible: 1 == msgDefaultAction(), command: replyCommand" data-i18n="[title]MESSAGE/BUTTON_REPLY">←</a> + <a class="btn btn-thin btn-transparent buttonReplyAll fontastic" + data-bind="visible: 2 == msgDefaultAction(), command: replyAllCommand" data-i18n="[title]MESSAGE/BUTTON_REPLY_ALL">↞</a> + </span> + <div class="btn-group" data-bind="registerBootstrapDropdown: true" style="display: inline-block"> + <a class="btn btn-thin btn-transparent dropdown-toggle fontastic" id="more-view-dropdown-id" href="#" tabindex="-1">☰</a> + <menu class="dropdown-menu right-edge" role="menu" aria-labelledby="more-view-dropdown-id"> + <li role="presentation"> + <a href="#" tabindex="-1" data-bind="command: replyCommand" data-icon="←" data-i18n="MESSAGE/BUTTON_REPLY"></a> + </li> + <li role="presentation"> + <a href="#" tabindex="-1" data-bind="command: replyAllCommand" data-icon="↞" data-i18n="MESSAGE/BUTTON_REPLY_ALL"></a> + </li> + <li role="presentation"> + <a href="#" tabindex="-1" data-bind="command: forwardCommand" data-icon="→" data-i18n="MESSAGE/BUTTON_FORWARD"></a> + </li> + <li role="presentation"> + <a href="#" tabindex="-1" data-bind="command: forwardAsAttachmentCommand" data-icon="⥅" data-i18n="MESSAGE/BUTTON_FORWARD_AS_ATTACHMENT"></a> + </li> + <li role="presentation"> + <a href="#" tabindex="-1" data-bind="command: editAsNewCommand" data-icon="🖉" data-i18n="MESSAGE/BUTTON_EDIT_AS_NEW"></a> + </li> + <li class="dividerbar" role="presentation" data-bind="visible: firstUnsubsribeLink"> + <a target="_blank" href="#" tabindex="-1" data-bind="attr: { href: firstUnsubsribeLink }" data-icon="✖" data-i18n="MESSAGE/BUTTON_UNSUBSCRIBE"></a> + </li> + <div data-bind="with: message" class="dividerbar"> + <li role="presentation"> + <a href="#" tabindex="-1" data-bind="click: printMessage" data-icon="🖨" data-i18n="MESSAGE/MENU_PRINT"></a> + </li> + <li role="presentation"> + <a href="#" tabindex="-1" data-bind="click: $root.toggleFullScreen"> + <i data-bind="css: { 'icon-arrows-out': !$root.fullScreenMode(), 'icon-arrows-in': $root.fullScreenMode }"></i> + <span data-i18n="SHORTCUTS_HELP/LABEL_FULLSCREEN_TOGGLE"></span> + </a> + </li> + <li role="presentation"> + <a href="#" tabindex="-1" data-bind="click: popupMessage" data-icon="⧉" data-i18n="MESSAGE/BUTTON_IN_NEW_WINDOW"></a> + </li> + <li role="presentation" data-bind="visible: html() && !isHtml()"> + <a href="#" tabindex="-1" data-bind="click: viewHtml" data-icon="👁" data-i18n="MESSAGE/HTML_VIEW"></a> + </li> + <li role="presentation" data-bind="visible: isHtml()"> + <a href="#" tabindex="-1" data-bind="click: viewPlain" data-icon="👁" data-i18n="MESSAGE/PLAIN_VIEW"></a> + </li> + </div> + </menu> + </div> + </div> + </div> + + <div class="messageItemHeader" data-bind="if: message, i18nUpdate: message"> + <div class="subjectParent"> + <span class="infoParent g-ui-user-select-none fontastic" data-bind="click: toggleFullInfo">ℹ</span> + <span class="flagParent g-ui-user-select-none flagOff fontastic" data-bind="text: message().isFlagged() ? '★' : '☆', css: {flagOn: message().isFlagged(), flagOff: !message().isFlagged()}"></span> + <span class="subject" data-bind="text: message().subject"></span> + </div> + <div class="informationShort"> + <span class="from" data-bind="html: viewFromShort, title: message().from"></span> + <i data-bind="visible: viewDkimIcon, css: dkimIconClass, title: dkimTitle"></i> + </div> + </div> + <div id="messageItem"> + <div class="messageItemHeader" data-bind="if: message, i18nUpdate: message"> + <div data-bind="hidden: showFullInfo"> + <time class="date" data-moment-format="FULL" data-bind="visible: 0 < message().dateTimestamp(), moment: message().dateTimestamp()"></time> + <div class="informationShortWrp"> + <div class="informationShort" data-bind="visible: message().to.length"> + <span data-i18n="GLOBAL/TO"></span>: + <span data-bind="text: message().to"></span> + </div> + <div class="informationShort" data-bind="visible: message().cc.length"> + <span data-i18n="GLOBAL/CC"></span>: + <span data-bind="text: message().cc"></span> + </div> + </div> + <div class="informationShort" data-bind="visible: message().spamResult()"> + <span data-i18n="MESSAGE/SPAM_SCORE"></span>: + <meter min="0" max="100" optimum="0" low="33" high="66" data-bind="value: message().spamScore(), title: message().spamStatus()"></meter> + </div> + </div> + <div class="informationFull" data-bind="visible: showFullInfo, with: message"> + <table> + <tr data-bind="visible: from.length"> + <td data-i18n="GLOBAL/FROM"></td> + <td><span data-bind="text: from"></span> + <i data-bind="visible: $parent.viewDkimIcon, css: $parent.dkimIconClass, title: $parent.dkimTitle"></i> + </td> + </tr> + <tr data-bind="visible: to.length"> + <td data-i18n="GLOBAL/TO"></td> + <td data-bind="text: to"></td> + </tr> + <tr data-bind="visible: cc.length"> + <td data-i18n="GLOBAL/CC"></td> + <td data-bind="text: cc"></td> + </tr> + <tr data-bind="visible: bcc.length"> + <td data-i18n="GLOBAL/BCC"></td> + <td data-bind="text: bcc"></td> + </tr> + <tr data-bind="visible: replyTo.length"> + <td data-i18n="GLOBAL/REPLY_TO"></td> + <td data-bind="text: replyTo"></td> + </tr> + <tr data-bind="visible: dateTimestamp"> + <td data-i18n="MESSAGE/LABEL_DATE"></td> + <td> + <time data-moment-format="FULL" data-bind="moment: dateTimestamp"></time> +   + (<time data-moment-format="FROMNOW" data-bind="moment: dateTimestamp"></time>) + </td> + </tr> + <tr data-bind="visible: spamResult"> + <td data-i18n="GLOBAL/SPAM"></td> + <td data-bind="text: spamStatus()"></td> + </tr> + <tr data-bind="visible: friendlySize()"> + <td data-i18n="POPUPS_FILTER/SELECT_FIELD_SIZE"></td> + <td class="size" data-bind="text: friendlySize()"></td> + </tr> + </table> + </div> + <div class="hasVirus" data-bind="visible: message().hasVirus()" data-i18n="MESSAGE/HAS_VIRUS_WARNING"></div> + <!-- ko if: tagsAllowed --> + <div class="messageTags"> + <span data-i18n="MESSAGE/TAGS"></span>: + <span class="messageAssignedTags" data-bind="html: tagsToHTML"></span> + </div> + <!-- /ko --> + </div> + <div tabindex="0" data-bind="hasfocus: messageDomFocused"> + <div class="bodySubHeader" data-bind="if: message, i18nUpdate: message"> + <details class="attachmentsPlace" data-bind="visible: hasAttachments, css: {'selection-mode' : showAttachmentControls}, attr: { open: hasAttachments }"> + <summary data-i18n="MESSAGE/PRINT_LABEL_ATTACHMENTS"></summary> + <!-- ko ifnot: simpleAttachmentsList --> + <ul class="attachmentList" data-bind="foreach: listAttachments"> + <li class="attachmentItem" draggable="true" + data-bind="event: { dragstart: eventDragStart }, attr: { title: fileName }, css: {checked: checked}"> + <div class="attachmentIcon" data-bind="css: { hasPreview: hasPreview(), hasPreplay: hasPreplay(), isImage: isImage() }, attr: { style: thumbnailStyle() }"> + <i class="hidePreview iconMain" data-bind="css: iconClass()"></i> + <div class="showPreview"> + <a data-bind="css: {attachmentImagePreview: isImage()}, attr: { title: fileName, href: linkPreviewMain() }" target="_blank"> + <i class="iconMain" data-bind="visible: !hasThumbnail(), css: iconClass()"></i> + <div class="iconPreview fontastic">👁</div> + </a> + </div> + </div> + <div class="attachmentNameParent"> + <div class="attachmentName" data-bind="text: fileName"></div> + <div class="attachmentSize" data-bind="text: friendlySize()"></div> + </div> + <div class="checkboxAttachment fontastic" + data-bind="visible: download, text: checked() ? '☑' : '☐', click: toggleChecked"></div> + </li> + </ul> + <!-- /ko --> + <!-- ko if: simpleAttachmentsList --> + <ul class="attachmentListSimple" data-bind="foreach: listAttachments"> + <li class="attachmentItem" data-bind="attr: { title: fileName }, css: {checked: checked}"> + <span class="attachmentName" data-bind="text: fileName"></span> + <i class="checkboxAttachment fontastic" + data-bind="visible: download, text: checked() ? '☑' : '☐', click: toggleChecked"></i> + </li> + </ul> + <!-- /ko --> + <i class="fontastic controls-handle" data-bind="visible: allowAttachmentControls, click: toggleAttachmentControls">⚙</i> + <div class="attachmentsControls" data-bind="visible: showAttachmentControls"> + <span data-bind="visible: downloadAsZipAllowed"> + <i class="fontastic iconcolor-red" data-bind="visible: downloadAsZipError">✖</i> + <i class="icon-file-archive" data-bind="visible: !downloadAsZipError(), + css: {'icon-file-archive': !downloadAsZipLoading(), 'icon-spinner': downloadAsZipLoading()}"></i> + <span class="g-ui-link" data-bind="click: downloadAsZip" + data-i18n="MESSAGE/LINK_DOWNLOAD_AS_ZIP"></span> + </span> + </div> + </details> + + <div class="btn-toolbar showImages" data-bind="visible: message().hasImages()"> + <div class="btn" data-bind="click: showImages" data-icon="🖼" data-i18n="MESSAGE/BUTTON_SHOW_IMAGES"></div> + <div class="btn-group dropdown" data-bind="registerBootstrapDropdown: true, visible: showWhitelistOptions" style="display: inline-block"> + <a class="btn dropdown-toggle" id="whitelist-dropdown-id" data-icon="🖼" href="#" tabindex="-1"><span data-i18n="SETTINGS_GENERAL/IMAGES_WHITELIST"></span> ▼</a> + <menu class="dropdown-menu" role="menu" aria-labelledby="whitelist-dropdown-id"> + <!-- ko foreach: message().whitelistOptions() --> + <li role="presentation"> + <a href="#" tabindex="-1" data-bind="click: $root.whitelistText, text: $data"></a> + </li> + <!-- /ko --> + </menu> + </div> + </div> + + <div data-bind="visible: message().pgpEncrypted()"> + <div class="openpgp-control encrypted" data-bind="css: {success: message().pgpDecrypted()}"> + <span data-icon="🔒" data-i18n="OPENPGP/ENCRYPTED_MESSAGE"></span> + <button class="btn" data-bind="visible: pgpSupported, click: pgpDecrypt" data-i18n="OPENPGP/BUTTON_DECRYPT"></button> + </div> + </div> + <div data-bind="visible: message().pgpSigned()"> + <div class="openpgp-control signed" data-bind="css: {success: message().pgpVerified() && message().pgpVerified().success, error: message().pgpVerified() && !message().pgpVerified().success}"> + <span data-icon="✍" data-i18n="OPENPGP/SIGNED_MESSAGE"></span> + <button class="btn" data-bind="visible: pgpSupported, click: pgpVerify" data-i18n="OPENPGP/BUTTON_VERIFY"></button> + </div> + </div> + </div> + + <div class="bodyText"></div> + </div> + </div> +</div> +<footer> +</footer>