webmail: show all images (inline and attachment) below the text part (for the text view, not for html view)

the attachment buttons for images get some opacity for the text view, to
indicate you don't have to open them explicitly.
This commit is contained in:
Mechiel Lukkien 2024-04-20 19:36:14 +02:00
parent 41a62de4d7
commit 3a58b2a1f4
No known key found for this signature in database
10 changed files with 133 additions and 51 deletions

View file

@ -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.

View file

@ -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.

View file

@ -7,11 +7,14 @@
<style>
* { font-size: inherit; font-family: 'ubuntu', 'lato', sans-serif; margin: 0; padding: 0; box-sizing: border-box; }
.mono, .mono * { font-family: 'ubuntu mono', monospace; }
.pad { padding: 1ex; }
.scriptswitch { text-decoration: underline #dca053 2px; }
.quoted1 { color: #03828f; }
.quoted2 { color: #c7445c; }
.quoted3 { color: #417c10; }
.textmulti > *:nth-child(even) { background-color: #f4f4f4; }
.textmulti > * { padding: 2ex .5em; margin: -.5em; /* compensate pad */ }
.textmulti > *:first-child { padding: .5em; }
.pad { padding: .5em; }
</style>
</head>
<body>

View file

@ -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) => {

View file

@ -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)'})
),
)
)
}),
)
)
}

View file

@ -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"

View file

@ -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; }

View file

@ -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) {

View file

@ -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) => {

View file

@ -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)