From fb81effe45cf657112f5744c01359e5d8bb847b8 Mon Sep 17 00:00:00 2001 From: Mechiel Lukkien Date: Mon, 27 Nov 2023 12:11:05 +0100 Subject: [PATCH] webmail: for domain in From address, show if domain is dmarc(-like) validated i'm not sure this is good enough. this is based on field MsgFromValidation, but it doesn't hold the full DMARC information. we also don't know mailing list-status for all historic messages. so the red underline can occur too often. --- webmail/lib.ts | 67 ++++++++++++++++++++++++++++++++++++++++++- webmail/msg.js | 65 +++++++++++++++++++++++++++++++++++++++++- webmail/text.js | 65 +++++++++++++++++++++++++++++++++++++++++- webmail/webmail.js | 71 +++++++++++++++++++++++++++++++++++++++++++--- webmail/webmail.ts | 6 ++-- 5 files changed, 264 insertions(+), 10 deletions(-) diff --git a/webmail/lib.ts b/webmail/lib.ts index 917294b..ac4ca5e 100644 --- a/webmail/lib.ts +++ b/webmail/lib.ts @@ -212,6 +212,13 @@ const prop = (x: {[k: string]: any}) => { return {_props: x}} return [dom, style, attr, prop] })() +// For authentication/security results. +const underlineGreen = '#50c40f' +const underlineRed = '#e15d1c' +const underlineBlue = '#09f' +const underlineGrey = '#aaa' +const underlineYellow = 'yellow' + // join elements in l with the results of calls to efn. efn can return // HTMLElements, which cannot be inserted into the dom multiple times, hence the // function. @@ -316,6 +323,64 @@ const formatAddressFull = (a: api.MessageAddress): string => { return s } +// like formatAddressFull, but underline domain with dmarc-like validation if appropriate. +const formatAddressFullValidated = (a: api.MessageAddress, m: api.Message, use: boolean): (string | HTMLElement)[] => { + const domainText = (s: string): HTMLElement | string => { + if (!use) { + return s + } + // We want to show how "approved" this message is given the message From's domain. + // We have MsgFromValidation available. It's not the greatest, being a mix of + // potential strict validations, actual DMARC policy validation, potential relaxed + // validation, but no explicit fail or (temporary) errors. We also don't know if + // historic messages were from a mailing list. We could add a heuristic based on + // List-Id headers, but it would be unreliable... + // todo: add field to Message with the exact results. + let color = '' + let title = '' + switch (m.MsgFromValidation) { + case api.Validation.ValidationStrict: + color = underlineGreen + title = 'Message would have matched a strict DMARC policy.' + break + case api.Validation.ValidationDMARC: + color = underlineGreen + title = 'Message matched DMARC policy of domain.' + break + case api.Validation.ValidationRelaxed: + color = underlineGreen + title = 'Domain did not have a DMARC policy, but message would match a relaxed policy if it had existed.' + break; + case api.Validation.ValidationNone: + if (m.IsForward || m.IsMailingList) { + color = underlineBlue + title = 'Message would not pass DMARC policy, but came in through a configured mailing list or forwarding address.' + } else { + color = underlineRed + title = 'Either domain did not have a DMARC policy, or message did not adhere to it.' + } + break; + default: + // Also for zero value, when unknown. E.g. for sent messages added with IMAP. + return dom.span(attr.title('Unknown DMARC verification result.'), s) + } + return dom.span(attr.title(title), style({borderBottom: '1.5px solid '+color, textDecoration: 'none'}), s) + } + + let l: (string | HTMLElement)[] = [] + if (a.Name) { + l.push(a.Name + ' ') + } + l.push('<' + a.User + '@') + l.push(domainText(a.Domain.ASCII)) + l.push('>') + if (a.Domain.Unicode) { + // Not underlining because unicode domain may already cause underlining. + l.push(' (' + a.User + '@' + a.Domain.Unicode+')') + } + return l +} + // format just the name if present and it doesn't look like an address, or otherwise just the email address. const formatAddressShort = (a: api.MessageAddress): string => { const n = a.Name @@ -373,7 +438,7 @@ const loadMsgheaderView = (msgheaderelem: HTMLElement, mi: api.MessageItem, more dom.td( style({width: '100%'}), dom.div(style({display: 'flex', justifyContent: 'space-between'}), - dom.div(join((msgenv.From || []).map(a => formatAddressFull(a)), () => ', ')), + dom.div(join((msgenv.From || []).map(a => formatAddressFullValidated(a, mi.Message, !!msgenv.From && msgenv.From.length === 1)), () => ', ')), dom.div( attr.title('Received: ' + received.toString() + ';\nDate header in message: ' + (msgenv.Date ? msgenv.Date.toString() : '(missing/invalid)')), receivedlocal.toDateString() + ' ' + receivedlocal.toTimeString().split(' ')[0], diff --git a/webmail/msg.js b/webmail/msg.js index 2b292f2..e2cda4f 100644 --- a/webmail/msg.js +++ b/webmail/msg.js @@ -860,6 +860,12 @@ const [dom, style, attr, prop] = (function () { const prop = (x) => { return { _props: x }; }; return [dom, style, attr, prop]; })(); +// For authentication/security results. +const underlineGreen = '#50c40f'; +const underlineRed = '#e15d1c'; +const underlineBlue = '#09f'; +const underlineGrey = '#aaa'; +const underlineYellow = 'yellow'; // join elements in l with the results of calls to efn. efn can return // HTMLElements, which cannot be inserted into the dom multiple times, hence the // function. @@ -959,6 +965,63 @@ const formatAddressFull = (a) => { } return s; }; +// like formatAddressFull, but underline domain with dmarc-like validation if appropriate. +const formatAddressFullValidated = (a, m, use) => { + const domainText = (s) => { + if (!use) { + return s; + } + // We want to show how "approved" this message is given the message From's domain. + // We have MsgFromValidation available. It's not the greatest, being a mix of + // potential strict validations, actual DMARC policy validation, potential relaxed + // validation, but no explicit fail or (temporary) errors. We also don't know if + // historic messages were from a mailing list. We could add a heuristic based on + // List-Id headers, but it would be unreliable... + // todo: add field to Message with the exact results. + let color = ''; + let title = ''; + switch (m.MsgFromValidation) { + case api.Validation.ValidationStrict: + color = underlineGreen; + title = 'Message would have matched a strict DMARC policy.'; + break; + case api.Validation.ValidationDMARC: + color = underlineGreen; + title = 'Message matched DMARC policy of domain.'; + break; + case api.Validation.ValidationRelaxed: + color = underlineGreen; + title = 'Domain did not have a DMARC policy, but message would match a relaxed policy if it had existed.'; + break; + case api.Validation.ValidationNone: + if (m.IsForward || m.IsMailingList) { + color = underlineBlue; + title = 'Message would not pass DMARC policy, but came in through a configured mailing list or forwarding address.'; + } + else { + color = underlineRed; + title = 'Either domain did not have a DMARC policy, or message did not adhere to it.'; + } + break; + default: + // Also for zero value, when unknown. E.g. for sent messages added with IMAP. + return dom.span(attr.title('Unknown DMARC verification result.'), s); + } + return dom.span(attr.title(title), style({ borderBottom: '1.5px solid ' + color, textDecoration: 'none' }), s); + }; + let l = []; + if (a.Name) { + l.push(a.Name + ' '); + } + l.push('<' + a.User + '@'); + l.push(domainText(a.Domain.ASCII)); + l.push('>'); + if (a.Domain.Unicode) { + // Not underlining because unicode domain may already cause underlining. + l.push(' (' + a.User + '@' + a.Domain.Unicode + ')'); + } + return l; +}; // format just the name if present and it doesn't look like an address, or otherwise just the email address. const formatAddressShort = (a) => { const n = a.Name; @@ -996,7 +1059,7 @@ const loadMsgheaderView = (msgheaderelem, mi, moreHeaders, refineKeyword, allAdd const receivedlocal = new Date(received.getTime()); dom._kids(msgheaderelem, // todo: make addresses clickable, start search (keep current mailbox if any) - dom.tr(dom.td('From:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(style({ width: '100%' }), dom.div(style({ display: 'flex', justifyContent: 'space-between' }), dom.div(join((msgenv.From || []).map(a => formatAddressFull(a)), () => ', ')), dom.div(attr.title('Received: ' + received.toString() + ';\nDate header in message: ' + (msgenv.Date ? msgenv.Date.toString() : '(missing/invalid)')), receivedlocal.toDateString() + ' ' + receivedlocal.toTimeString().split(' ')[0])))), (msgenv.ReplyTo || []).length === 0 ? [] : dom.tr(dom.td('Reply-To:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(join((msgenv.ReplyTo || []).map(a => formatAddressFull(a)), () => ', '))), dom.tr(dom.td('To:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(addressList(allAddrs, msgenv.To || []))), (msgenv.CC || []).length === 0 ? [] : dom.tr(dom.td('Cc:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(addressList(allAddrs, msgenv.CC || []))), (msgenv.BCC || []).length === 0 ? [] : dom.tr(dom.td('Bcc:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(addressList(allAddrs, msgenv.BCC || []))), dom.tr(dom.td('Subject:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(dom.div(style({ display: 'flex', justifyContent: 'space-between' }), dom.div(msgenv.Subject || ''), dom.div(mi.Message.IsForward ? dom.span(style({ padding: '0px 0.15em', fontSize: '.9em' }), 'Forwarded', attr.title('Message came in from a forwarded address. Some message authentication policies, like DMARC, were not evaluated.')) : [], mi.Message.IsMailingList ? dom.span(style({ padding: '0px 0.15em', fontSize: '.9em' }), 'Mailing list', attr.title('Message was received from a mailing list. Some message authentication policies, like DMARC, were not evaluated.')) : [], mi.Message.ReceivedTLSVersion === 1 ? dom.span(style({ padding: '0px 0.15em', fontSize: '.9em', borderBottom: '1.5px solid #e15d1c' }), 'Without TLS', attr.title('Message received (last hop) without TLS.')) : [], mi.Message.ReceivedTLSVersion > 1 && !mi.Message.ReceivedRequireTLS ? dom.span(style({ padding: '0px 0.15em', fontSize: '.9em', borderBottom: '1.5px solid #50c40f' }), 'With TLS', attr.title('Message received (last hop) with TLS.')) : [], mi.Message.ReceivedRequireTLS ? dom.span(style({ padding: '.1em .3em', fontSize: '.9em', backgroundColor: '#d2f791', border: '1px solid #ccc', borderRadius: '3px' }), 'With RequireTLS', attr.title('Transported with RequireTLS, ensuring TLS along the entire delivery path from sender to recipient, with TLS certificate verification through MTA-STS and/or DANE.')) : [], mi.IsSigned ? dom.span(style({ backgroundColor: '#666', padding: '0px 0.15em', fontSize: '.9em', color: 'white', borderRadius: '.15em' }), 'Message has a signature') : [], mi.IsEncrypted ? dom.span(style({ backgroundColor: '#666', padding: '0px 0.15em', fontSize: '.9em', color: 'white', borderRadius: '.15em' }), 'Message is encrypted') : [], refineKeyword ? (mi.Message.Keywords || []).map(kw => dom.clickbutton(dom._class('keyword'), kw, async function click() { + dom.tr(dom.td('From:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(style({ width: '100%' }), dom.div(style({ display: 'flex', justifyContent: 'space-between' }), dom.div(join((msgenv.From || []).map(a => formatAddressFullValidated(a, mi.Message, !!msgenv.From && msgenv.From.length === 1)), () => ', ')), dom.div(attr.title('Received: ' + received.toString() + ';\nDate header in message: ' + (msgenv.Date ? msgenv.Date.toString() : '(missing/invalid)')), receivedlocal.toDateString() + ' ' + receivedlocal.toTimeString().split(' ')[0])))), (msgenv.ReplyTo || []).length === 0 ? [] : dom.tr(dom.td('Reply-To:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(join((msgenv.ReplyTo || []).map(a => formatAddressFull(a)), () => ', '))), dom.tr(dom.td('To:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(addressList(allAddrs, msgenv.To || []))), (msgenv.CC || []).length === 0 ? [] : dom.tr(dom.td('Cc:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(addressList(allAddrs, msgenv.CC || []))), (msgenv.BCC || []).length === 0 ? [] : dom.tr(dom.td('Bcc:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(addressList(allAddrs, msgenv.BCC || []))), dom.tr(dom.td('Subject:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(dom.div(style({ display: 'flex', justifyContent: 'space-between' }), dom.div(msgenv.Subject || ''), dom.div(mi.Message.IsForward ? dom.span(style({ padding: '0px 0.15em', fontSize: '.9em' }), 'Forwarded', attr.title('Message came in from a forwarded address. Some message authentication policies, like DMARC, were not evaluated.')) : [], mi.Message.IsMailingList ? dom.span(style({ padding: '0px 0.15em', fontSize: '.9em' }), 'Mailing list', attr.title('Message was received from a mailing list. Some message authentication policies, like DMARC, were not evaluated.')) : [], mi.Message.ReceivedTLSVersion === 1 ? dom.span(style({ padding: '0px 0.15em', fontSize: '.9em', borderBottom: '1.5px solid #e15d1c' }), 'Without TLS', attr.title('Message received (last hop) without TLS.')) : [], mi.Message.ReceivedTLSVersion > 1 && !mi.Message.ReceivedRequireTLS ? dom.span(style({ padding: '0px 0.15em', fontSize: '.9em', borderBottom: '1.5px solid #50c40f' }), 'With TLS', attr.title('Message received (last hop) with TLS.')) : [], mi.Message.ReceivedRequireTLS ? dom.span(style({ padding: '.1em .3em', fontSize: '.9em', backgroundColor: '#d2f791', border: '1px solid #ccc', borderRadius: '3px' }), 'With RequireTLS', attr.title('Transported with RequireTLS, ensuring TLS along the entire delivery path from sender to recipient, with TLS certificate verification through MTA-STS and/or DANE.')) : [], mi.IsSigned ? dom.span(style({ backgroundColor: '#666', padding: '0px 0.15em', fontSize: '.9em', color: 'white', borderRadius: '.15em' }), 'Message has a signature') : [], mi.IsEncrypted ? dom.span(style({ backgroundColor: '#666', padding: '0px 0.15em', fontSize: '.9em', color: 'white', borderRadius: '.15em' }), 'Message is encrypted') : [], refineKeyword ? (mi.Message.Keywords || []).map(kw => dom.clickbutton(dom._class('keyword'), kw, async function click() { await refineKeyword(kw); })) : [])))), moreHeaders.map(k => dom.tr(dom.td(k + ':', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td()))); }; diff --git a/webmail/text.js b/webmail/text.js index e2d02f8..3ff447e 100644 --- a/webmail/text.js +++ b/webmail/text.js @@ -860,6 +860,12 @@ const [dom, style, attr, prop] = (function () { const prop = (x) => { return { _props: x }; }; return [dom, style, attr, prop]; })(); +// For authentication/security results. +const underlineGreen = '#50c40f'; +const underlineRed = '#e15d1c'; +const underlineBlue = '#09f'; +const underlineGrey = '#aaa'; +const underlineYellow = 'yellow'; // join elements in l with the results of calls to efn. efn can return // HTMLElements, which cannot be inserted into the dom multiple times, hence the // function. @@ -959,6 +965,63 @@ const formatAddressFull = (a) => { } return s; }; +// like formatAddressFull, but underline domain with dmarc-like validation if appropriate. +const formatAddressFullValidated = (a, m, use) => { + const domainText = (s) => { + if (!use) { + return s; + } + // We want to show how "approved" this message is given the message From's domain. + // We have MsgFromValidation available. It's not the greatest, being a mix of + // potential strict validations, actual DMARC policy validation, potential relaxed + // validation, but no explicit fail or (temporary) errors. We also don't know if + // historic messages were from a mailing list. We could add a heuristic based on + // List-Id headers, but it would be unreliable... + // todo: add field to Message with the exact results. + let color = ''; + let title = ''; + switch (m.MsgFromValidation) { + case api.Validation.ValidationStrict: + color = underlineGreen; + title = 'Message would have matched a strict DMARC policy.'; + break; + case api.Validation.ValidationDMARC: + color = underlineGreen; + title = 'Message matched DMARC policy of domain.'; + break; + case api.Validation.ValidationRelaxed: + color = underlineGreen; + title = 'Domain did not have a DMARC policy, but message would match a relaxed policy if it had existed.'; + break; + case api.Validation.ValidationNone: + if (m.IsForward || m.IsMailingList) { + color = underlineBlue; + title = 'Message would not pass DMARC policy, but came in through a configured mailing list or forwarding address.'; + } + else { + color = underlineRed; + title = 'Either domain did not have a DMARC policy, or message did not adhere to it.'; + } + break; + default: + // Also for zero value, when unknown. E.g. for sent messages added with IMAP. + return dom.span(attr.title('Unknown DMARC verification result.'), s); + } + return dom.span(attr.title(title), style({ borderBottom: '1.5px solid ' + color, textDecoration: 'none' }), s); + }; + let l = []; + if (a.Name) { + l.push(a.Name + ' '); + } + l.push('<' + a.User + '@'); + l.push(domainText(a.Domain.ASCII)); + l.push('>'); + if (a.Domain.Unicode) { + // Not underlining because unicode domain may already cause underlining. + l.push(' (' + a.User + '@' + a.Domain.Unicode + ')'); + } + return l; +}; // format just the name if present and it doesn't look like an address, or otherwise just the email address. const formatAddressShort = (a) => { const n = a.Name; @@ -996,7 +1059,7 @@ const loadMsgheaderView = (msgheaderelem, mi, moreHeaders, refineKeyword, allAdd const receivedlocal = new Date(received.getTime()); dom._kids(msgheaderelem, // todo: make addresses clickable, start search (keep current mailbox if any) - dom.tr(dom.td('From:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(style({ width: '100%' }), dom.div(style({ display: 'flex', justifyContent: 'space-between' }), dom.div(join((msgenv.From || []).map(a => formatAddressFull(a)), () => ', ')), dom.div(attr.title('Received: ' + received.toString() + ';\nDate header in message: ' + (msgenv.Date ? msgenv.Date.toString() : '(missing/invalid)')), receivedlocal.toDateString() + ' ' + receivedlocal.toTimeString().split(' ')[0])))), (msgenv.ReplyTo || []).length === 0 ? [] : dom.tr(dom.td('Reply-To:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(join((msgenv.ReplyTo || []).map(a => formatAddressFull(a)), () => ', '))), dom.tr(dom.td('To:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(addressList(allAddrs, msgenv.To || []))), (msgenv.CC || []).length === 0 ? [] : dom.tr(dom.td('Cc:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(addressList(allAddrs, msgenv.CC || []))), (msgenv.BCC || []).length === 0 ? [] : dom.tr(dom.td('Bcc:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(addressList(allAddrs, msgenv.BCC || []))), dom.tr(dom.td('Subject:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(dom.div(style({ display: 'flex', justifyContent: 'space-between' }), dom.div(msgenv.Subject || ''), dom.div(mi.Message.IsForward ? dom.span(style({ padding: '0px 0.15em', fontSize: '.9em' }), 'Forwarded', attr.title('Message came in from a forwarded address. Some message authentication policies, like DMARC, were not evaluated.')) : [], mi.Message.IsMailingList ? dom.span(style({ padding: '0px 0.15em', fontSize: '.9em' }), 'Mailing list', attr.title('Message was received from a mailing list. Some message authentication policies, like DMARC, were not evaluated.')) : [], mi.Message.ReceivedTLSVersion === 1 ? dom.span(style({ padding: '0px 0.15em', fontSize: '.9em', borderBottom: '1.5px solid #e15d1c' }), 'Without TLS', attr.title('Message received (last hop) without TLS.')) : [], mi.Message.ReceivedTLSVersion > 1 && !mi.Message.ReceivedRequireTLS ? dom.span(style({ padding: '0px 0.15em', fontSize: '.9em', borderBottom: '1.5px solid #50c40f' }), 'With TLS', attr.title('Message received (last hop) with TLS.')) : [], mi.Message.ReceivedRequireTLS ? dom.span(style({ padding: '.1em .3em', fontSize: '.9em', backgroundColor: '#d2f791', border: '1px solid #ccc', borderRadius: '3px' }), 'With RequireTLS', attr.title('Transported with RequireTLS, ensuring TLS along the entire delivery path from sender to recipient, with TLS certificate verification through MTA-STS and/or DANE.')) : [], mi.IsSigned ? dom.span(style({ backgroundColor: '#666', padding: '0px 0.15em', fontSize: '.9em', color: 'white', borderRadius: '.15em' }), 'Message has a signature') : [], mi.IsEncrypted ? dom.span(style({ backgroundColor: '#666', padding: '0px 0.15em', fontSize: '.9em', color: 'white', borderRadius: '.15em' }), 'Message is encrypted') : [], refineKeyword ? (mi.Message.Keywords || []).map(kw => dom.clickbutton(dom._class('keyword'), kw, async function click() { + dom.tr(dom.td('From:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(style({ width: '100%' }), dom.div(style({ display: 'flex', justifyContent: 'space-between' }), dom.div(join((msgenv.From || []).map(a => formatAddressFullValidated(a, mi.Message, !!msgenv.From && msgenv.From.length === 1)), () => ', ')), dom.div(attr.title('Received: ' + received.toString() + ';\nDate header in message: ' + (msgenv.Date ? msgenv.Date.toString() : '(missing/invalid)')), receivedlocal.toDateString() + ' ' + receivedlocal.toTimeString().split(' ')[0])))), (msgenv.ReplyTo || []).length === 0 ? [] : dom.tr(dom.td('Reply-To:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(join((msgenv.ReplyTo || []).map(a => formatAddressFull(a)), () => ', '))), dom.tr(dom.td('To:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(addressList(allAddrs, msgenv.To || []))), (msgenv.CC || []).length === 0 ? [] : dom.tr(dom.td('Cc:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(addressList(allAddrs, msgenv.CC || []))), (msgenv.BCC || []).length === 0 ? [] : dom.tr(dom.td('Bcc:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(addressList(allAddrs, msgenv.BCC || []))), dom.tr(dom.td('Subject:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(dom.div(style({ display: 'flex', justifyContent: 'space-between' }), dom.div(msgenv.Subject || ''), dom.div(mi.Message.IsForward ? dom.span(style({ padding: '0px 0.15em', fontSize: '.9em' }), 'Forwarded', attr.title('Message came in from a forwarded address. Some message authentication policies, like DMARC, were not evaluated.')) : [], mi.Message.IsMailingList ? dom.span(style({ padding: '0px 0.15em', fontSize: '.9em' }), 'Mailing list', attr.title('Message was received from a mailing list. Some message authentication policies, like DMARC, were not evaluated.')) : [], mi.Message.ReceivedTLSVersion === 1 ? dom.span(style({ padding: '0px 0.15em', fontSize: '.9em', borderBottom: '1.5px solid #e15d1c' }), 'Without TLS', attr.title('Message received (last hop) without TLS.')) : [], mi.Message.ReceivedTLSVersion > 1 && !mi.Message.ReceivedRequireTLS ? dom.span(style({ padding: '0px 0.15em', fontSize: '.9em', borderBottom: '1.5px solid #50c40f' }), 'With TLS', attr.title('Message received (last hop) with TLS.')) : [], mi.Message.ReceivedRequireTLS ? dom.span(style({ padding: '.1em .3em', fontSize: '.9em', backgroundColor: '#d2f791', border: '1px solid #ccc', borderRadius: '3px' }), 'With RequireTLS', attr.title('Transported with RequireTLS, ensuring TLS along the entire delivery path from sender to recipient, with TLS certificate verification through MTA-STS and/or DANE.')) : [], mi.IsSigned ? dom.span(style({ backgroundColor: '#666', padding: '0px 0.15em', fontSize: '.9em', color: 'white', borderRadius: '.15em' }), 'Message has a signature') : [], mi.IsEncrypted ? dom.span(style({ backgroundColor: '#666', padding: '0px 0.15em', fontSize: '.9em', color: 'white', borderRadius: '.15em' }), 'Message is encrypted') : [], refineKeyword ? (mi.Message.Keywords || []).map(kw => dom.clickbutton(dom._class('keyword'), kw, async function click() { await refineKeyword(kw); })) : [])))), moreHeaders.map(k => dom.tr(dom.td(k + ':', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td()))); }; diff --git a/webmail/webmail.js b/webmail/webmail.js index a78a3a0..c9950a6 100644 --- a/webmail/webmail.js +++ b/webmail/webmail.js @@ -860,6 +860,12 @@ const [dom, style, attr, prop] = (function () { const prop = (x) => { return { _props: x }; }; return [dom, style, attr, prop]; })(); +// For authentication/security results. +const underlineGreen = '#50c40f'; +const underlineRed = '#e15d1c'; +const underlineBlue = '#09f'; +const underlineGrey = '#aaa'; +const underlineYellow = 'yellow'; // join elements in l with the results of calls to efn. efn can return // HTMLElements, which cannot be inserted into the dom multiple times, hence the // function. @@ -959,6 +965,63 @@ const formatAddressFull = (a) => { } return s; }; +// like formatAddressFull, but underline domain with dmarc-like validation if appropriate. +const formatAddressFullValidated = (a, m, use) => { + const domainText = (s) => { + if (!use) { + return s; + } + // We want to show how "approved" this message is given the message From's domain. + // We have MsgFromValidation available. It's not the greatest, being a mix of + // potential strict validations, actual DMARC policy validation, potential relaxed + // validation, but no explicit fail or (temporary) errors. We also don't know if + // historic messages were from a mailing list. We could add a heuristic based on + // List-Id headers, but it would be unreliable... + // todo: add field to Message with the exact results. + let color = ''; + let title = ''; + switch (m.MsgFromValidation) { + case api.Validation.ValidationStrict: + color = underlineGreen; + title = 'Message would have matched a strict DMARC policy.'; + break; + case api.Validation.ValidationDMARC: + color = underlineGreen; + title = 'Message matched DMARC policy of domain.'; + break; + case api.Validation.ValidationRelaxed: + color = underlineGreen; + title = 'Domain did not have a DMARC policy, but message would match a relaxed policy if it had existed.'; + break; + case api.Validation.ValidationNone: + if (m.IsForward || m.IsMailingList) { + color = underlineBlue; + title = 'Message would not pass DMARC policy, but came in through a configured mailing list or forwarding address.'; + } + else { + color = underlineRed; + title = 'Either domain did not have a DMARC policy, or message did not adhere to it.'; + } + break; + default: + // Also for zero value, when unknown. E.g. for sent messages added with IMAP. + return dom.span(attr.title('Unknown DMARC verification result.'), s); + } + return dom.span(attr.title(title), style({ borderBottom: '1.5px solid ' + color, textDecoration: 'none' }), s); + }; + let l = []; + if (a.Name) { + l.push(a.Name + ' '); + } + l.push('<' + a.User + '@'); + l.push(domainText(a.Domain.ASCII)); + l.push('>'); + if (a.Domain.Unicode) { + // Not underlining because unicode domain may already cause underlining. + l.push(' (' + a.User + '@' + a.Domain.Unicode + ')'); + } + return l; +}; // format just the name if present and it doesn't look like an address, or otherwise just the email address. const formatAddressShort = (a) => { const n = a.Name; @@ -996,7 +1059,7 @@ const loadMsgheaderView = (msgheaderelem, mi, moreHeaders, refineKeyword, allAdd const receivedlocal = new Date(received.getTime()); dom._kids(msgheaderelem, // todo: make addresses clickable, start search (keep current mailbox if any) - dom.tr(dom.td('From:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(style({ width: '100%' }), dom.div(style({ display: 'flex', justifyContent: 'space-between' }), dom.div(join((msgenv.From || []).map(a => formatAddressFull(a)), () => ', ')), dom.div(attr.title('Received: ' + received.toString() + ';\nDate header in message: ' + (msgenv.Date ? msgenv.Date.toString() : '(missing/invalid)')), receivedlocal.toDateString() + ' ' + receivedlocal.toTimeString().split(' ')[0])))), (msgenv.ReplyTo || []).length === 0 ? [] : dom.tr(dom.td('Reply-To:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(join((msgenv.ReplyTo || []).map(a => formatAddressFull(a)), () => ', '))), dom.tr(dom.td('To:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(addressList(allAddrs, msgenv.To || []))), (msgenv.CC || []).length === 0 ? [] : dom.tr(dom.td('Cc:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(addressList(allAddrs, msgenv.CC || []))), (msgenv.BCC || []).length === 0 ? [] : dom.tr(dom.td('Bcc:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(addressList(allAddrs, msgenv.BCC || []))), dom.tr(dom.td('Subject:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(dom.div(style({ display: 'flex', justifyContent: 'space-between' }), dom.div(msgenv.Subject || ''), dom.div(mi.Message.IsForward ? dom.span(style({ padding: '0px 0.15em', fontSize: '.9em' }), 'Forwarded', attr.title('Message came in from a forwarded address. Some message authentication policies, like DMARC, were not evaluated.')) : [], mi.Message.IsMailingList ? dom.span(style({ padding: '0px 0.15em', fontSize: '.9em' }), 'Mailing list', attr.title('Message was received from a mailing list. Some message authentication policies, like DMARC, were not evaluated.')) : [], mi.Message.ReceivedTLSVersion === 1 ? dom.span(style({ padding: '0px 0.15em', fontSize: '.9em', borderBottom: '1.5px solid #e15d1c' }), 'Without TLS', attr.title('Message received (last hop) without TLS.')) : [], mi.Message.ReceivedTLSVersion > 1 && !mi.Message.ReceivedRequireTLS ? dom.span(style({ padding: '0px 0.15em', fontSize: '.9em', borderBottom: '1.5px solid #50c40f' }), 'With TLS', attr.title('Message received (last hop) with TLS.')) : [], mi.Message.ReceivedRequireTLS ? dom.span(style({ padding: '.1em .3em', fontSize: '.9em', backgroundColor: '#d2f791', border: '1px solid #ccc', borderRadius: '3px' }), 'With RequireTLS', attr.title('Transported with RequireTLS, ensuring TLS along the entire delivery path from sender to recipient, with TLS certificate verification through MTA-STS and/or DANE.')) : [], mi.IsSigned ? dom.span(style({ backgroundColor: '#666', padding: '0px 0.15em', fontSize: '.9em', color: 'white', borderRadius: '.15em' }), 'Message has a signature') : [], mi.IsEncrypted ? dom.span(style({ backgroundColor: '#666', padding: '0px 0.15em', fontSize: '.9em', color: 'white', borderRadius: '.15em' }), 'Message is encrypted') : [], refineKeyword ? (mi.Message.Keywords || []).map(kw => dom.clickbutton(dom._class('keyword'), kw, async function click() { + dom.tr(dom.td('From:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(style({ width: '100%' }), dom.div(style({ display: 'flex', justifyContent: 'space-between' }), dom.div(join((msgenv.From || []).map(a => formatAddressFullValidated(a, mi.Message, !!msgenv.From && msgenv.From.length === 1)), () => ', ')), dom.div(attr.title('Received: ' + received.toString() + ';\nDate header in message: ' + (msgenv.Date ? msgenv.Date.toString() : '(missing/invalid)')), receivedlocal.toDateString() + ' ' + receivedlocal.toTimeString().split(' ')[0])))), (msgenv.ReplyTo || []).length === 0 ? [] : dom.tr(dom.td('Reply-To:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(join((msgenv.ReplyTo || []).map(a => formatAddressFull(a)), () => ', '))), dom.tr(dom.td('To:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(addressList(allAddrs, msgenv.To || []))), (msgenv.CC || []).length === 0 ? [] : dom.tr(dom.td('Cc:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(addressList(allAddrs, msgenv.CC || []))), (msgenv.BCC || []).length === 0 ? [] : dom.tr(dom.td('Bcc:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(addressList(allAddrs, msgenv.BCC || []))), dom.tr(dom.td('Subject:', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td(dom.div(style({ display: 'flex', justifyContent: 'space-between' }), dom.div(msgenv.Subject || ''), dom.div(mi.Message.IsForward ? dom.span(style({ padding: '0px 0.15em', fontSize: '.9em' }), 'Forwarded', attr.title('Message came in from a forwarded address. Some message authentication policies, like DMARC, were not evaluated.')) : [], mi.Message.IsMailingList ? dom.span(style({ padding: '0px 0.15em', fontSize: '.9em' }), 'Mailing list', attr.title('Message was received from a mailing list. Some message authentication policies, like DMARC, were not evaluated.')) : [], mi.Message.ReceivedTLSVersion === 1 ? dom.span(style({ padding: '0px 0.15em', fontSize: '.9em', borderBottom: '1.5px solid #e15d1c' }), 'Without TLS', attr.title('Message received (last hop) without TLS.')) : [], mi.Message.ReceivedTLSVersion > 1 && !mi.Message.ReceivedRequireTLS ? dom.span(style({ padding: '0px 0.15em', fontSize: '.9em', borderBottom: '1.5px solid #50c40f' }), 'With TLS', attr.title('Message received (last hop) with TLS.')) : [], mi.Message.ReceivedRequireTLS ? dom.span(style({ padding: '.1em .3em', fontSize: '.9em', backgroundColor: '#d2f791', border: '1px solid #ccc', borderRadius: '3px' }), 'With RequireTLS', attr.title('Transported with RequireTLS, ensuring TLS along the entire delivery path from sender to recipient, with TLS certificate verification through MTA-STS and/or DANE.')) : [], mi.IsSigned ? dom.span(style({ backgroundColor: '#666', padding: '0px 0.15em', fontSize: '.9em', color: 'white', borderRadius: '.15em' }), 'Message has a signature') : [], mi.IsEncrypted ? dom.span(style({ backgroundColor: '#666', padding: '0px 0.15em', fontSize: '.9em', color: 'white', borderRadius: '.15em' }), 'Message is encrypted') : [], refineKeyword ? (mi.Message.Keywords || []).map(kw => dom.clickbutton(dom._class('keyword'), kw, async function click() { await refineKeyword(kw); })) : [])))), moreHeaders.map(k => dom.tr(dom.td(k + ':', style({ textAlign: 'right', color: '#555', whiteSpace: 'nowrap' })), dom.td()))); }; @@ -2106,15 +2169,15 @@ const compose = (opts) => { } const color = (v) => { if (v === api.SecurityResult.SecurityResultYes) { - return '#50c40f'; + return underlineGreen; } else if (v === api.SecurityResult.SecurityResultNo) { - return '#e15d1c'; + return underlineRed; } else if (v === api.SecurityResult.SecurityResultUnknown) { return 'white'; } - return '#aaa'; + return underlineGrey; }; const setBar = (c0, c1, c2, c3, c4) => { const stops = [ diff --git a/webmail/webmail.ts b/webmail/webmail.ts index 1f4bfe5..88e079e 100644 --- a/webmail/webmail.ts +++ b/webmail/webmail.ts @@ -1289,13 +1289,13 @@ const compose = (opts: ComposeOptions) => { const color = (v: api.SecurityResult) => { if (v === api.SecurityResult.SecurityResultYes) { - return '#50c40f' + return underlineGreen } else if (v === api.SecurityResult.SecurityResultNo) { - return '#e15d1c' + return underlineRed } else if (v === api.SecurityResult.SecurityResultUnknown) { return 'white' } - return '#aaa' + return underlineGrey } const setBar = (c0: string, c1: string, c2: string, c3: string, c4: string) => { const stops = [