diff --git a/webmail/lib.ts b/webmail/lib.ts index 8c1abf3..751f754 100644 --- a/webmail/lib.ts +++ b/webmail/lib.ts @@ -22,6 +22,18 @@ const join = (l: any, efn: () => any): any[] => { return r } +// From https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types +const imageTypes = [ + 'image/avif', + 'image/webp', + 'image/gif', + 'image/png', + 'image/jpeg', + 'image/apng', + 'image/svg+xml', +] +const isImage = (a: api.Attachment) => imageTypes.includes((a.Part.MediaType + '/' + a.Part.MediaSubType).toLowerCase()) + // addLinks turns a line of text into alternating strings and links. Links that // would end with interpunction followed by whitespace are returned with that // interpunction moved to the next string instead. diff --git a/webmail/msg.js b/webmail/msg.js index 71d77f1..14757c1 100644 --- a/webmail/msg.js +++ b/webmail/msg.js @@ -1009,6 +1009,17 @@ const join = (l, efn) => { } return r; }; +// From https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types +const imageTypes = [ + 'image/avif', + 'image/webp', + 'image/gif', + 'image/png', + 'image/jpeg', + 'image/apng', + 'image/svg+xml', +]; +const isImage = (a) => imageTypes.includes((a.Part.MediaType + '/' + a.Part.MediaSubType).toLowerCase()); // addLinks turns a line of text into alternating strings and links. Links that // would end with interpunction followed by whitespace are returned with that // interpunction moved to the next string instead. diff --git a/webmail/text.html b/webmail/text.html index ea6104c..d5fa00f 100644 --- a/webmail/text.html +++ b/webmail/text.html @@ -7,11 +7,14 @@ diff --git a/webmail/text.js b/webmail/text.js index c248803..68605bf 100644 --- a/webmail/text.js +++ b/webmail/text.js @@ -1009,6 +1009,17 @@ const join = (l, efn) => { } return r; }; +// From https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types +const imageTypes = [ + 'image/avif', + 'image/webp', + 'image/gif', + 'image/png', + 'image/jpeg', + 'image/apng', + 'image/svg+xml', +]; +const isImage = (a) => imageTypes.includes((a.Part.MediaType + '/' + a.Part.MediaSubType).toLowerCase()); // addLinks turns a line of text into alternating strings and links. Links that // would end with interpunction followed by whitespace are returned with that // interpunction moved to the next string instead. @@ -1186,7 +1197,11 @@ const loadMsgheaderView = (msgheaderelem, mi, moreHeaders, refineKeyword, allAdd // Javascript is generated from typescript, do not modify generated javascript because changes will be overwritten. const init = async () => { const pm = api.parser.ParsedMessage(parsedMessage); - dom._kids(document.body, dom.div(dom._class('pad', 'mono'), style({ whiteSpace: 'pre-wrap' }), join((pm.Texts || []).map(t => renderText(t)), () => dom.hr(style({ margin: '2ex 0' }))))); + const mi = api.parser.MessageItem(messageItem); + dom._kids(document.body, dom.div(dom._class('pad', 'mono', 'textmulti'), style({ whiteSpace: 'pre-wrap' }), (pm.Texts || []).map(t => renderText(t.replace(/\r\n/g, '\n'))), (mi.Attachments || []).filter(f => isImage(f)).map(f => { + const pathStr = [0].concat(f.Path || []).join('.'); + return dom.div(dom.div(style({ flexGrow: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', maxHeight: 'calc(100% - 50px)' }), dom.img(attr.src('view/' + pathStr), attr.title(f.Filename), style({ backgroundColor: 'white', maxWidth: '100%', maxHeight: '100%', boxShadow: '0 0 20px rgba(0, 0, 0, 0.1)' })))); + }))); }; init() .catch((err) => { diff --git a/webmail/text.ts b/webmail/text.ts index c9e61fd..a4659bc 100644 --- a/webmail/text.ts +++ b/webmail/text.ts @@ -1,14 +1,29 @@ // Javascript is generated from typescript, do not modify generated javascript because changes will be overwritten. // Loaded from synchronous javascript. +declare let messageItem: api.MessageItem declare let parsedMessage: api.ParsedMessage const init = async () => { const pm = api.parser.ParsedMessage(parsedMessage) + const mi = api.parser.MessageItem(messageItem) dom._kids(document.body, - dom.div(dom._class('pad', 'mono'), + dom.div(dom._class('pad', 'mono', 'textmulti'), style({whiteSpace: 'pre-wrap'}), - join((pm.Texts || []).map(t => renderText(t)), () => dom.hr(style({margin: '2ex 0'}))), + (pm.Texts || []).map(t => renderText(t.replace(/\r\n/g, '\n'))), + (mi.Attachments || []).filter(f => isImage(f)).map(f => { + const pathStr = [0].concat(f.Path || []).join('.') + return dom.div( + dom.div( + style({flexGrow: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', maxHeight: 'calc(100% - 50px)'}), + dom.img( + attr.src('view/'+pathStr), + attr.title(f.Filename), + style({backgroundColor: 'white', maxWidth: '100%', maxHeight: '100%', boxShadow: '0 0 20px rgba(0, 0, 0, 0.1)'}) + ), + ) + ) + }), ) ) } diff --git a/webmail/webmail.go b/webmail/webmail.go index caf7518..544860b 100644 --- a/webmail/webmail.go +++ b/webmail/webmail.go @@ -377,7 +377,7 @@ func handle(apiHandler http.Handler, isForwarded bool, accountPath string, w htt // allowed. Used to display a message including header. The header is rendered with // javascript, the content is rendered in a separate iframe with a CSP that doesn't // have allowSelfScript. - headers := func(sameOrigin, allowExternal, allowSelfScript bool) { + headers := func(sameOrigin, allowExternal, allowSelfScript, allowSelfImg bool) { // allow-popups is needed to make opening links in new tabs work. sb := "sandbox allow-popups allow-popups-to-escape-sandbox; " if sameOrigin && allowSelfScript { @@ -394,6 +394,8 @@ func handle(apiHandler http.Handler, isForwarded bool, accountPath string, w htt var csp string if allowExternal { csp = sb + "frame-ancestors 'self'; default-src 'none'; img-src data: http: https: 'unsafe-inline'; style-src 'unsafe-inline' data: http: https:; font-src data: http: https: 'unsafe-inline'; media-src 'unsafe-inline' data: http: https:" + script + } else if allowSelfImg { + csp = sb + "frame-ancestors 'self'; default-src 'none'; img-src data: 'self'; style-src 'unsafe-inline'" + script } else { csp = sb + "frame-ancestors 'self'; default-src 'none'; img-src data:; style-src 'unsafe-inline'" + script } @@ -416,7 +418,7 @@ func handle(apiHandler http.Handler, isForwarded bool, accountPath string, w htt mi, err := messageItem(log, m, &state) xcheckf(ctx, err, "parsing message") - headers(false, false, false) + headers(false, false, false, false) h.Set("Content-Type", "application/zip") h.Set("Cache-Control", "no-store, max-age=0") var subjectSlug string @@ -536,7 +538,7 @@ func handle(apiHandler http.Handler, isForwarded bool, accountPath string, w htt // browsers or users would think of executing. We do set the charset if available // on the outer part. If present, we assume it may be relevant for other parts. If // not, there is not much we could do better... - headers(false, false, false) + headers(false, false, false, false) ct := "text/plain" params := map[string]string{} if charset := p.ContentTypeParams["charset"]; charset != "" { @@ -571,7 +573,7 @@ func handle(apiHandler http.Handler, isForwarded bool, accountPath string, w htt sameorigin := true loadExternal := t[1] == "msghtmlexternal" allowSelfScript := true - headers(sameorigin, loadExternal, allowSelfScript) + headers(sameorigin, loadExternal, allowSelfScript, false) h.Set("Content-Type", "text/html; charset=utf-8") h.Set("Cache-Control", "no-store, max-age=0") @@ -604,7 +606,7 @@ func handle(apiHandler http.Handler, isForwarded bool, accountPath string, w htt mijson, err := json.Marshal(mi) xcheckf(ctx, err, "marshal messageitem") - headers(false, false, false) + headers(false, false, false, false) h.Set("Content-Type", "application/javascript; charset=utf-8") h.Set("Cache-Control", "no-store, max-age=0") @@ -636,7 +638,8 @@ func handle(apiHandler http.Handler, isForwarded bool, accountPath string, w htt // Needed for inner document height for outer iframe height in separate message view. sameorigin := true allowSelfScript := true - headers(sameorigin, false, allowSelfScript) + allowSelfImg := true + headers(sameorigin, false, allowSelfScript, allowSelfImg) h.Set("Content-Type", "text/html; charset=utf-8") h.Set("Cache-Control", "no-store, max-age=0") @@ -662,7 +665,7 @@ func handle(apiHandler http.Handler, isForwarded bool, accountPath string, w htt // inner height so we load it as different origin, which should be safer. sameorigin := r.URL.Query().Get("sameorigin") == "true" allowExternal := strings.HasSuffix(t[1], "external") - headers(sameorigin, allowExternal, false) + headers(sameorigin, allowExternal, false, false) h.Set("Content-Type", "text/html; charset=utf-8") h.Set("Cache-Control", "no-store, max-age=0") @@ -724,7 +727,7 @@ func handle(apiHandler http.Handler, isForwarded bool, accountPath string, w htt ap = ap.Parts[int(index)] } - headers(false, false, false) + headers(false, false, false, false) var ct string if t[1] == "viewtext" { ct = "text/plain" diff --git a/webmail/webmail.html b/webmail/webmail.html index 9154bba..7a5305c 100644 --- a/webmail/webmail.html +++ b/webmail/webmail.html @@ -27,9 +27,12 @@ button.keyword { cursor: pointer; } .btngroup button:first-child, .btngroup .button:first-child { border-radius: .15em 0 0 .15em; } .btngroup button:last-child, .btngroup .button:last-child { border-radius: 0 .15em .15em 0; border-right-width: 1px; } iframe { border: 0; } -.pad { padding: .5em; } .invert { filter: invert(100%); } .scriptswitch { text-decoration: underline #dca053 2px; } +.textmulti > *:nth-child(even) { background-color: #f4f4f4; } +.textmulti > * { padding: 2ex .5em; margin: -.5em; /* compensate pad */ } +.textmulti > *:first-child { padding: .5em; } +.pad { padding: .5em; } .msgitem { display: flex; user-select: none; } .msgitemcell { padding: 2px 4px; } diff --git a/webmail/webmail.js b/webmail/webmail.js index ec2325f..18d877a 100644 --- a/webmail/webmail.js +++ b/webmail/webmail.js @@ -1009,6 +1009,17 @@ const join = (l, efn) => { } return r; }; +// From https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types +const imageTypes = [ + 'image/avif', + 'image/webp', + 'image/gif', + 'image/png', + 'image/jpeg', + 'image/apng', + 'image/svg+xml', +]; +const isImage = (a) => imageTypes.includes((a.Part.MediaType + '/' + a.Part.MediaSubType).toLowerCase()); // addLinks turns a line of text into alternating strings and links. Links that // would end with interpunction followed by whitespace are returned with that // interpunction moved to the next string instead. @@ -3565,18 +3576,7 @@ const newMsgView = (miv, msglistView, listMailboxes, possibleLabels, messageLoad msgheaderdetailsElem = dom.table(style({ marginBottom: '1ex', width: '100%' }), Object.entries(pm.Headers || {}).sort().map(t => (t[1] || []).map(v => dom.tr(dom.td(t[0] + ':', style({ textAlign: 'right', color: '#555' })), dom.td(v))))); msgattachmentElem.parentNode.insertBefore(msgheaderdetailsElem, msgattachmentElem); }; - // From https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types - const imageTypes = [ - 'image/avif', - 'image/webp', - 'image/gif', - 'image/png', - 'image/jpeg', - 'image/apng', - 'image/svg+xml', - ]; const isText = (a) => ['text', 'message'].includes(a.Part.MediaType.toLowerCase()); - const isImage = (a) => imageTypes.includes((a.Part.MediaType + '/' + a.Part.MediaSubType).toLowerCase()); const isPDF = (a) => (a.Part.MediaType + '/' + a.Part.MediaSubType).toLowerCase() === 'application/pdf'; const isViewable = (a) => isText(a) || isImage(a) || isPDF(a); const attachments = (mi.Attachments || []); @@ -3643,9 +3643,10 @@ const newMsgView = (miv, msglistView, listMailboxes, possibleLabels, messageLoad popupRoot.focus(); attachmentView = { key: keyHandler(attachShortcuts) }; }; - const renderAttachments = (all) => { + var filesAll = false; + const renderAttachments = () => { const l = mi.Attachments || []; - dom._kids(msgattachmentElem, (l && l.length === 0) ? [] : dom.div(style({ borderTop: '1px solid #ccc' }), dom.div(dom._class('pad'), 'Attachments: ', l.slice(0, all ? l.length : 4).map(a => { + dom._kids(msgattachmentElem, (l && l.length === 0) ? [] : dom.div(style({ borderTop: '1px solid #ccc' }), dom.div(dom._class('pad'), 'Attachments: ', l.slice(0, filesAll ? l.length : 4).map(a => { const name = a.Filename || '(unnamed)'; const viewable = isViewable(a); const size = formatSize(a.Part.DecodedSize); @@ -3657,14 +3658,15 @@ const newMsgView = (miv, msglistView, listMailboxes, possibleLabels, messageLoad }); const dlbtn = dom.a(dom._class('button'), attr.download(''), attr.href(dlurl), dl, viewable ? style({ padding: '0px 0.25em' }) : ' ' + name, attr.title('Download this file. Size: ' + size), style({ lineHeight: '1.5' })); if (viewable) { - return [dom.span(dom._class('btngroup'), viewbtn, dlbtn), ' ']; + return [dom.span(dom._class('btngroup'), urlType === 'text' && isImage(a) ? style({ opacity: '.6' }) : [], viewbtn, dlbtn), ' ']; } return [dom.span(dom._class('btngroup'), dlbtn, viewbtn), ' ']; - }), all || l.length < 6 ? [] : dom.clickbutton('More...', function click() { - renderAttachments(true); + }), filesAll || l.length < 6 ? [] : dom.clickbutton('More...', function click() { + filesAll = true; + renderAttachments(); }), ' ', dom.a('Download all as zip', attr.download(''), style({ color: 'inherit' }), attr.href('msg/' + m.ID + '/attachments.zip'))))); }; - renderAttachments(false); + renderAttachments(); const root = dom.div(style({ position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, display: 'flex', flexDirection: 'column' })); dom._kids(root, msgmetaElem, msgcontentElem); const loadText = (pm) => { @@ -3672,18 +3674,24 @@ const newMsgView = (miv, msglistView, listMailboxes, possibleLabels, messageLoad // text to use when writing a reply. We still set url so the text content can be // opened in a separate tab, even though it will look differently. urlType = 'text'; - const elem = dom.div(dom._class('mono'), style({ whiteSpace: 'pre-wrap' }), join((pm.Texts || []).map(t => renderText(t.replace(/\r\n/g, '\n'))), () => dom.hr(style({ margin: '2ex 0' })))); + const elem = dom.div(dom._class('mono', 'textmulti'), style({ whiteSpace: 'pre-wrap' }), (pm.Texts || []).map(t => renderText(t.replace(/\r\n/g, '\n'))), (mi.Attachments || []).filter(f => isImage(f)).map(f => { + const pathStr = [0].concat(f.Path || []).join('.'); + return dom.div(dom.div(style({ flexGrow: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', maxHeight: 'calc(100% - 50px)' }), dom.img(attr.src('msg/' + m.ID + '/view/' + pathStr), attr.title(f.Filename), style({ backgroundColor: 'white', maxWidth: '100%', maxHeight: '100%', boxShadow: '0 0 20px rgba(0, 0, 0, 0.1)' })))); + })); dom._kids(msgcontentElem); dom._kids(msgscrollElem, elem); dom._kids(msgcontentElem, msgscrollElem); + renderAttachments(); // Rerender opaciy on inline images. }; const loadHTML = () => { urlType = 'html'; dom._kids(msgcontentElem, dom.iframe(attr.tabindex('0'), attr.title('HTML version of message with images inlined, without external resources loaded.'), attr.src('msg/' + m.ID + '/' + urlType), style({ border: '0', position: 'absolute', width: '100%', height: '100%', backgroundColor: 'white' }))); + renderAttachments(); // Rerender opaciy on inline images. }; const loadHTMLexternal = () => { urlType = 'htmlexternal'; dom._kids(msgcontentElem, dom.iframe(attr.tabindex('0'), attr.title('HTML version of message with images inlined and with external resources loaded.'), attr.src('msg/' + m.ID + '/' + urlType), style({ border: '0', position: 'absolute', width: '100%', height: '100%', backgroundColor: 'white' }))); + renderAttachments(); // Rerender opaciy on inline images. }; const loadMoreHeaders = (pm) => { if (settings.showHeaders.length === 0) { diff --git a/webmail/webmail.ts b/webmail/webmail.ts index 26191e4..8265c3d 100644 --- a/webmail/webmail.ts +++ b/webmail/webmail.ts @@ -3065,18 +3065,7 @@ const newMsgView = (miv: MsgitemView, msglistView: MsglistView, listMailboxes: l msgattachmentElem.parentNode!.insertBefore(msgheaderdetailsElem, msgattachmentElem) } - // From https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types - const imageTypes = [ - 'image/avif', - 'image/webp', - 'image/gif', - 'image/png', - 'image/jpeg', - 'image/apng', - 'image/svg+xml', - ] const isText = (a: api.Attachment) => ['text', 'message'].includes(a.Part.MediaType.toLowerCase()) - const isImage = (a: api.Attachment) => imageTypes.includes((a.Part.MediaType + '/' + a.Part.MediaSubType).toLowerCase()) const isPDF = (a: api.Attachment) => (a.Part.MediaType+'/'+a.Part.MediaSubType).toLowerCase() === 'application/pdf' const isViewable = (a: api.Attachment) => isText(a) || isImage(a) || isPDF(a) const attachments: api.Attachment[] = (mi.Attachments || []) @@ -3215,14 +3204,15 @@ const newMsgView = (miv: MsgitemView, msglistView: MsglistView, listMailboxes: l attachmentView = {key: keyHandler(attachShortcuts)} } - const renderAttachments = (all: boolean) => { + var filesAll = false + const renderAttachments = () => { const l = mi.Attachments || [] dom._kids(msgattachmentElem, (l && l.length === 0) ? [] : dom.div( style({borderTop: '1px solid #ccc'}), dom.div(dom._class('pad'), 'Attachments: ', - l.slice(0, all ? l.length : 4).map(a => { + l.slice(0, filesAll ? l.length : 4).map(a => { const name = a.Filename || '(unnamed)' const viewable = isViewable(a) const size = formatSize(a.Part.DecodedSize) @@ -3234,19 +3224,20 @@ const newMsgView = (miv: MsgitemView, msglistView: MsglistView, listMailboxes: l }) const dlbtn = dom.a(dom._class('button'), attr.download(''), attr.href(dlurl), dl, viewable ? style({padding: '0px 0.25em'}) : ' '+name, attr.title('Download this file. Size: '+size), style({lineHeight: '1.5'})) if (viewable) { - return [dom.span(dom._class('btngroup'), viewbtn, dlbtn), ' '] + return [dom.span(dom._class('btngroup'), urlType === 'text' && isImage(a) ? style({opacity: '.6'}) : [], viewbtn, dlbtn), ' '] } return [dom.span(dom._class('btngroup'), dlbtn, viewbtn), ' '] }), - all || l.length < 6 ? [] : dom.clickbutton('More...', function click() { - renderAttachments(true) + filesAll || l.length < 6 ? [] : dom.clickbutton('More...', function click() { + filesAll = true + renderAttachments() }), ' ', dom.a('Download all as zip', attr.download(''), style({color: 'inherit'}), attr.href('msg/'+m.ID+'/attachments.zip')), ), ) ) } - renderAttachments(false) + renderAttachments() const root = dom.div(style({position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, display: 'flex', flexDirection: 'column'})) dom._kids(root, msgmetaElem, msgcontentElem) @@ -3256,13 +3247,27 @@ const newMsgView = (miv: MsgitemView, msglistView: MsglistView, listMailboxes: l // text to use when writing a reply. We still set url so the text content can be // opened in a separate tab, even though it will look differently. urlType = 'text' - const elem = dom.div(dom._class('mono'), + const elem = dom.div(dom._class('mono', 'textmulti'), style({whiteSpace: 'pre-wrap'}), - join((pm.Texts || []).map(t => renderText(t.replace(/\r\n/g, '\n'))), () => dom.hr(style({margin: '2ex 0'}))), + (pm.Texts || []).map(t => renderText(t.replace(/\r\n/g, '\n'))), + (mi.Attachments || []).filter(f => isImage(f)).map(f => { + const pathStr = [0].concat(f.Path || []).join('.') + return dom.div( + dom.div( + style({flexGrow: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', maxHeight: 'calc(100% - 50px)'}), + dom.img( + attr.src('msg/'+m.ID+'/view/'+pathStr), + attr.title(f.Filename), + style({backgroundColor: 'white', maxWidth: '100%', maxHeight: '100%', boxShadow: '0 0 20px rgba(0, 0, 0, 0.1)'}) + ), + ) + ) + }), ) dom._kids(msgcontentElem) dom._kids(msgscrollElem, elem) dom._kids(msgcontentElem, msgscrollElem) + renderAttachments() // Rerender opaciy on inline images. } const loadHTML = (): void => { urlType = 'html' @@ -3274,6 +3279,7 @@ const newMsgView = (miv: MsgitemView, msglistView: MsglistView, listMailboxes: l style({border: '0', position: 'absolute', width: '100%', height: '100%', backgroundColor: 'white'}), ) ) + renderAttachments() // Rerender opaciy on inline images. } const loadHTMLexternal = (): void => { urlType = 'htmlexternal' @@ -3285,6 +3291,7 @@ const newMsgView = (miv: MsgitemView, msglistView: MsglistView, listMailboxes: l style({border: '0', position: 'absolute', width: '100%', height: '100%', backgroundColor: 'white'}), ) ) + renderAttachments() // Rerender opaciy on inline images. } const loadMoreHeaders = (pm: api.ParsedMessage) => { diff --git a/webmail/webmail_test.go b/webmail/webmail_test.go index cfcb223..c92358f 100644 --- a/webmail/webmail_test.go +++ b/webmail/webmail_test.go @@ -530,6 +530,11 @@ func TestWebmail(t *testing.T) { "Content-Security-Policy", "frame-ancestors 'self'; default-src 'none'; img-src data:; style-src 'unsafe-inline'; script-src 'unsafe-inline' 'self'; frame-src 'self'; connect-src 'self'", } + // Text and img-src 'self', for viewing image files inline. + cspTextImg := [2]string{ + "Content-Security-Policy", + "frame-ancestors 'self'; default-src 'none'; img-src data: 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline' 'self'; frame-src 'self'; connect-src 'self'", + } // HTML as viewed in the regular viewer, not in a new tab. cspHTML := [2]string{ "Content-Security-Policy", @@ -560,7 +565,7 @@ func TestWebmail(t *testing.T) { "Content-Security-Policy", "frame-ancestors 'self'; default-src 'none'; img-src data: http: https: 'unsafe-inline'; style-src 'unsafe-inline' data: http: https:; font-src data: http: https: 'unsafe-inline'; media-src 'unsafe-inline' data: http: https:; script-src 'unsafe-inline' 'self'; frame-src 'self'; connect-src 'self'", } - testHTTPAuthREST("GET", pathInboxAltRel+"/text", http.StatusOK, httpHeaders{ctHTML, cspText}, nil) + testHTTPAuthREST("GET", pathInboxAltRel+"/text", http.StatusOK, httpHeaders{ctHTML, cspTextImg}, nil) testHTTPAuthREST("GET", pathInboxAltRel+"/html", http.StatusOK, httpHeaders{ctHTML, cspHTML}, nil) testHTTPAuthREST("GET", pathInboxAltRel+"/htmlexternal", http.StatusOK, httpHeaders{ctHTML, cspHTMLExternal}, nil) testHTTPAuthREST("GET", pathInboxAltRel+"/msgtext", http.StatusOK, httpHeaders{ctHTML, cspText}, nil)