diff --git a/rfc/index.txt b/rfc/index.txt index 7198ea5..85cf99c 100644 --- a/rfc/index.txt +++ b/rfc/index.txt @@ -10,7 +10,7 @@ Each tab-separated row has: - RFC title If the support status column value starts with a minus, it isn't included on -the protocol page on the website. Valid words for implementaiton status: +the protocol page on the website. Valid words for implementation status: - Yes, support is deemed complete - Partial, support is partial, more work can be done - Roadmap, no support, but it is planned @@ -389,6 +389,7 @@ See implementation guide, https://jmap.io/server.html 3339 -? - Date and Time on the Internet: Timestamps 3986 -? - Uniform Resource Identifier (URI): Generic Syntax 5617 -? - (Historic) DomainKeys Identified Mail (DKIM) Author Domain Signing Practices (ADSP) +6068 -Yes - The 'mailto' URI Scheme 6186 -? - (not used in practice) Use of SRV Records for Locating Email Submission/Access Services 7817 -? - Updated Transport Layer Security (TLS) Server Identity Check Procedure for Email-Related Protocols diff --git a/webmail/api.go b/webmail/api.go index 894da60..4627500 100644 --- a/webmail/api.go +++ b/webmail/api.go @@ -1782,6 +1782,13 @@ func recipientSecurity(ctx context.Context, resolver dns.Resolver, messageAddres return rs, nil } +// DecodeMIMEWords decodes Q/B-encoded words for a mime headers into UTF-8 text. +func (Webmail) DecodeMIMEWords(ctx context.Context, text string) string { + s, err := wordDecoder.DecodeHeader(text) + xcheckuserf(ctx, err, "decoding mime q/b-word encoded header") + return s +} + func slicesAny[T any](l []T) []any { r := make([]any, len(l)) for i, v := range l { diff --git a/webmail/api.json b/webmail/api.json index 473bdb3..5e72024 100644 --- a/webmail/api.json +++ b/webmail/api.json @@ -346,6 +346,26 @@ } ] }, + { + "Name": "DecodeMIMEWords", + "Docs": "DecodeMIMEWords decodes Q/B-encoded words for a mime headers into UTF-8 text.", + "Params": [ + { + "Name": "text", + "Typewords": [ + "string" + ] + } + ], + "Returns": [ + { + "Name": "r0", + "Typewords": [ + "string" + ] + } + ] + }, { "Name": "SSETypes", "Docs": "SSETypes exists to ensure the generated API contains the types, for use in SSE events.", diff --git a/webmail/api.ts b/webmail/api.ts index ea3a1cc..93fd574 100644 --- a/webmail/api.ts +++ b/webmail/api.ts @@ -848,6 +848,15 @@ export class Client { return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as RecipientSecurity } + // DecodeMIMEWords decodes Q/B-encoded words for a mime headers into UTF-8 text. + async DecodeMIMEWords(text: string): Promise { + const fn: string = "DecodeMIMEWords" + const paramTypes: string[][] = [["string"]] + const returnTypes: string[][] = [["string"]] + const params: any[] = [text] + return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as string + } + // SSETypes exists to ensure the generated API contains the types, for use in SSE events. async SSETypes(): Promise<[EventStart, EventViewErr, EventViewReset, EventViewMsgs, EventViewChanges, ChangeMsgAdd, ChangeMsgRemove, ChangeMsgFlags, ChangeMsgThread, ChangeMailboxRemove, ChangeMailboxAdd, ChangeMailboxRename, ChangeMailboxCounts, ChangeMailboxSpecialUse, ChangeMailboxKeywords, Flags]> { const fn: string = "SSETypes" diff --git a/webmail/lib.ts b/webmail/lib.ts index 9806a44..c8b02a0 100644 --- a/webmail/lib.ts +++ b/webmail/lib.ts @@ -27,7 +27,7 @@ const join = (l: any, efn: () => any): any[] => { // interpunction moved to the next string instead. const addLinks = (text: string): (HTMLAnchorElement | string)[] => { // todo: look at ../rfc/3986 and fix up regexp. we should probably accept utf-8. - const re = RegExp('(http|https):\/\/([:%0-9a-zA-Z._~!$&\'/()*+,;=-]+@)?([\\[\\]0-9a-zA-Z.-]+)(:[0-9]+)?([:@%0-9a-zA-Z._~!$&\'/()*+,;=-]*)(\\?[:@%0-9a-zA-Z._~!$&\'/()*+,;=?-]*)?(#[:@%0-9a-zA-Z._~!$&\'/()*+,;=?-]*)?') + const re = RegExp('(?:(http|https):\/\/|mailto:)([:%0-9a-zA-Z._~!$&\'/()*+,;=-]+@)?([\\[\\]0-9a-zA-Z.-]+)(:[0-9]+)?([:@%0-9a-zA-Z._~!$&\'/()*+,;=-]*)(\\?[:@%0-9a-zA-Z._~!$&\'/()*+,;=?-]*)?(#[:@%0-9a-zA-Z._~!$&\'/()*+,;=?-]*)?') const r = [] while (text.length > 0) { const l = re.exec(text) @@ -50,7 +50,7 @@ const addLinks = (text: string): (HTMLAnchorElement | string)[] => { url = url.substring(0, url.length-1) } } - r.push(dom.a(url, attr.href(url), attr.target('_blank'), attr.rel('noopener noreferrer'))) + r.push(dom.a(url, attr.href(url), url.startsWith('mailto:') ? [] : [attr.target('_blank'), attr.rel('noopener noreferrer')])) } return r } diff --git a/webmail/msg.js b/webmail/msg.js index fb4d371..094d8ce 100644 --- a/webmail/msg.js +++ b/webmail/msg.js @@ -576,6 +576,14 @@ var api; const params = [messageAddressee]; return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params); } + // DecodeMIMEWords decodes Q/B-encoded words for a mime headers into UTF-8 text. + async DecodeMIMEWords(text) { + const fn = "DecodeMIMEWords"; + const paramTypes = [["string"]]; + const returnTypes = [["string"]]; + const params = [text]; + return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params); + } // SSETypes exists to ensure the generated API contains the types, for use in SSE events. async SSETypes() { const fn = "SSETypes"; @@ -961,7 +969,7 @@ const join = (l, efn) => { // interpunction moved to the next string instead. const addLinks = (text) => { // todo: look at ../rfc/3986 and fix up regexp. we should probably accept utf-8. - const re = RegExp('(http|https):\/\/([:%0-9a-zA-Z._~!$&\'/()*+,;=-]+@)?([\\[\\]0-9a-zA-Z.-]+)(:[0-9]+)?([:@%0-9a-zA-Z._~!$&\'/()*+,;=-]*)(\\?[:@%0-9a-zA-Z._~!$&\'/()*+,;=?-]*)?(#[:@%0-9a-zA-Z._~!$&\'/()*+,;=?-]*)?'); + const re = RegExp('(?:(http|https):\/\/|mailto:)([:%0-9a-zA-Z._~!$&\'/()*+,;=-]+@)?([\\[\\]0-9a-zA-Z.-]+)(:[0-9]+)?([:@%0-9a-zA-Z._~!$&\'/()*+,;=-]*)(\\?[:@%0-9a-zA-Z._~!$&\'/()*+,;=?-]*)?(#[:@%0-9a-zA-Z._~!$&\'/()*+,;=?-]*)?'); const r = []; while (text.length > 0) { const l = re.exec(text); @@ -985,7 +993,7 @@ const addLinks = (text) => { url = url.substring(0, url.length - 1); } } - r.push(dom.a(url, attr.href(url), attr.target('_blank'), attr.rel('noopener noreferrer'))); + r.push(dom.a(url, attr.href(url), url.startsWith('mailto:') ? [] : [attr.target('_blank'), attr.rel('noopener noreferrer')])); } return r; }; diff --git a/webmail/text.js b/webmail/text.js index 8009152..e7dacc3 100644 --- a/webmail/text.js +++ b/webmail/text.js @@ -576,6 +576,14 @@ var api; const params = [messageAddressee]; return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params); } + // DecodeMIMEWords decodes Q/B-encoded words for a mime headers into UTF-8 text. + async DecodeMIMEWords(text) { + const fn = "DecodeMIMEWords"; + const paramTypes = [["string"]]; + const returnTypes = [["string"]]; + const params = [text]; + return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params); + } // SSETypes exists to ensure the generated API contains the types, for use in SSE events. async SSETypes() { const fn = "SSETypes"; @@ -961,7 +969,7 @@ const join = (l, efn) => { // interpunction moved to the next string instead. const addLinks = (text) => { // todo: look at ../rfc/3986 and fix up regexp. we should probably accept utf-8. - const re = RegExp('(http|https):\/\/([:%0-9a-zA-Z._~!$&\'/()*+,;=-]+@)?([\\[\\]0-9a-zA-Z.-]+)(:[0-9]+)?([:@%0-9a-zA-Z._~!$&\'/()*+,;=-]*)(\\?[:@%0-9a-zA-Z._~!$&\'/()*+,;=?-]*)?(#[:@%0-9a-zA-Z._~!$&\'/()*+,;=?-]*)?'); + const re = RegExp('(?:(http|https):\/\/|mailto:)([:%0-9a-zA-Z._~!$&\'/()*+,;=-]+@)?([\\[\\]0-9a-zA-Z.-]+)(:[0-9]+)?([:@%0-9a-zA-Z._~!$&\'/()*+,;=-]*)(\\?[:@%0-9a-zA-Z._~!$&\'/()*+,;=?-]*)?(#[:@%0-9a-zA-Z._~!$&\'/()*+,;=?-]*)?'); const r = []; while (text.length > 0) { const l = re.exec(text); @@ -985,7 +993,7 @@ const addLinks = (text) => { url = url.substring(0, url.length - 1); } } - r.push(dom.a(url, attr.href(url), attr.target('_blank'), attr.rel('noopener noreferrer'))); + r.push(dom.a(url, attr.href(url), url.startsWith('mailto:') ? [] : [attr.target('_blank'), attr.rel('noopener noreferrer')])); } return r; }; diff --git a/webmail/webmail.js b/webmail/webmail.js index 8501866..ac24e72 100644 --- a/webmail/webmail.js +++ b/webmail/webmail.js @@ -576,6 +576,14 @@ var api; const params = [messageAddressee]; return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params); } + // DecodeMIMEWords decodes Q/B-encoded words for a mime headers into UTF-8 text. + async DecodeMIMEWords(text) { + const fn = "DecodeMIMEWords"; + const paramTypes = [["string"]]; + const returnTypes = [["string"]]; + const params = [text]; + return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params); + } // SSETypes exists to ensure the generated API contains the types, for use in SSE events. async SSETypes() { const fn = "SSETypes"; @@ -961,7 +969,7 @@ const join = (l, efn) => { // interpunction moved to the next string instead. const addLinks = (text) => { // todo: look at ../rfc/3986 and fix up regexp. we should probably accept utf-8. - const re = RegExp('(http|https):\/\/([:%0-9a-zA-Z._~!$&\'/()*+,;=-]+@)?([\\[\\]0-9a-zA-Z.-]+)(:[0-9]+)?([:@%0-9a-zA-Z._~!$&\'/()*+,;=-]*)(\\?[:@%0-9a-zA-Z._~!$&\'/()*+,;=?-]*)?(#[:@%0-9a-zA-Z._~!$&\'/()*+,;=?-]*)?'); + const re = RegExp('(?:(http|https):\/\/|mailto:)([:%0-9a-zA-Z._~!$&\'/()*+,;=-]+@)?([\\[\\]0-9a-zA-Z.-]+)(:[0-9]+)?([:@%0-9a-zA-Z._~!$&\'/()*+,;=-]*)(\\?[:@%0-9a-zA-Z._~!$&\'/()*+,;=?-]*)?(#[:@%0-9a-zA-Z._~!$&\'/()*+,;=?-]*)?'); const r = []; while (text.length > 0) { const l = re.exec(text); @@ -985,7 +993,7 @@ const addLinks = (text) => { url = url.substring(0, url.length - 1); } } - r.push(dom.a(url, attr.href(url), attr.target('_blank'), attr.rel('noopener noreferrer'))); + r.push(dom.a(url, attr.href(url), url.startsWith('mailto:') ? [] : [attr.target('_blank'), attr.rel('noopener noreferrer')])); } return r; }; @@ -1875,10 +1883,15 @@ const focusPlaceholder = (s) => { }, ]; }; -// Parse a location hash into search terms (if any), selected message id (if -// any) and filters. -// Optional message id at the end, with ",". -// Otherwise mailbox or 'search '-prefix search string: #Inbox or #Inbox,1 or "#search mb:Inbox" or "#search mb:Inbox,1" +// Parse a location hash, with either mailbox or search terms, and optional +// selected message id. The special "#compose " hash, used for handling +// "mailto:"-links, must be handled before calling this function. +// +// Examples: +// #Inbox +// #Inbox,1 +// #search mb:Inbox +// #search mb:Inbox,1 const parseLocationHash = (mailboxlistView) => { let hash = decodeURIComponent((window.location.hash || '#').substring(1)); const m = hash.match(/,([0-9]+)$/); @@ -2144,7 +2157,32 @@ const cmdHelp = async () => { settingsPut({ ...settings, showShortcuts: true }); remove(); cmdHelp(); - })), dom.div(style({ marginTop: '2ex' }), 'Mox is open source email server software, this is version ' + moxversion + '. Feedback, including bug reports, is appreciated! ', link('https://github.com/mjl-/mox/issues/new'), '.')))); + })), dom.div(style({ marginTop: '2ex' }), 'To start composing a message when opening a "mailto:" link, register this application with your browser/system. ', dom.clickbutton('Register', attr.title('In most browsers, registering is only allowed on HTTPS URLs. Your browser may ask for confirmation. If nothing appears to happen, the registration may already have been present.'), function click() { + if (!window.navigator.registerProtocolHandler) { + window.alert('Registering a protocol handler ("mailto:") is not supported by your browser.'); + return; + } + try { + window.navigator.registerProtocolHandler('mailto', '#compose %s'); + } + catch (err) { + window.alert('Error registering "mailto:" protocol handler: ' + errmsg(err)); + } + }), ' ', dom.clickbutton('Unregister', attr.title('Not all browsers implement unregistering via JavaScript.'), function click() { + // Not supported on firefox at the time of writing, and the signature is not in the types. + if (!window.navigator.unregisterProtocolHandler) { + window.alert('Unregistering a protocol handler ("mailto:") via JavaScript is not supported by your browser. See your browser settings to unregister.'); + return; + } + try { + window.navigator.unregisterProtocolHandler('mailto', '#compose %s'); + } + catch (err) { + window.alert('Error unregistering "mailto:" protocol handler: ' + errmsg(err)); + return; + } + window.alert('"mailto:" protocol handler unregistered.'); + })), dom.div(style({ marginTop: '2ex' }), 'Mox is open source email server software, this is version ' + moxversion + '. Feedback, including bug reports, is appreciated! ', link('https://github.com/mjl-/mox/issues/new'), '.')))); }; // Show tooltips for either the focused element, or otherwise for all elements // that aren't reachable with tabindex and aren't marked specially to prevent @@ -5399,6 +5437,35 @@ const newSearchView = (searchbarElem, mailboxlistView, startSearch, searchViewCl }; return searchView; }; +// parse the "mailto:..." part (already decoded) of a "#compose mailto:..." url hash. +const parseComposeMailto = (mailto) => { + const u = new URL(mailto); + const addresses = (s) => s.split(',').filter(s => !!s); + const opts = {}; + opts.to = addresses(u.pathname).map(s => decodeURIComponent(s)); + for (const [xk, v] of new URLSearchParams(u.search)) { + const k = xk.toLowerCase(); + if (k === 'to') { + opts.to = [...opts.to, ...addresses(v)]; + } + else if (k === 'cc') { + opts.cc = [...(opts.cc || []), ...addresses(v)]; + } + else if (k === 'bcc') { + opts.bcc = [...(opts.bcc || []), ...addresses(v)]; + } + else if (k === 'subject') { + // q/b-word encoding is allowed, we let the server decode when we start composoing, + // only if needed. ../rfc/6068:267 + opts.subject = v; + } + else if (k === 'body') { + opts.body = v; + } + // todo: we ignore other headers for now. we should handle in-reply-to and references at some point. but we don't allow any custom headers at the time of writing. + } + return opts; +}; const init = async () => { let connectionElem; // SSE connection status/error. Empty when connected. let layoutElem; // Select dropdown for layout. @@ -5968,7 +6035,33 @@ const init = async () => { } checkMsglistWidth(); }); - window.addEventListener('hashchange', async () => { + window.addEventListener('hashchange', async (e) => { + const hash = decodeURIComponent(window.location.hash); + if (hash.startsWith('#compose ')) { + try { + const opts = parseComposeMailto(hash.substring('#compose '.length)); + // Restore previous hash. + if (e.oldURL) { + const ou = new URL(e.oldURL); + window.location.hash = ou.hash; + } + else { + window.location.hash = ''; + } + (async () => { + // Resolve Q/B-word mime encoding for subject. ../rfc/6068:267 ../rfc/2047:180 + if (opts.subject && opts.subject.includes('=?')) { + opts.subject = await withStatus('Decoding MIME words for subject', client.DecodeMIMEWords(opts.subject)); + } + compose(opts); + })(); + } + catch (err) { + window.alert('Error parsing compose mailto URL: ' + errmsg(err)); + window.location.hash = ''; + } + return; + } const [search, msgid, f, notf] = parseLocationHash(mailboxlistView); requestMsgID = msgid; if (search) { @@ -6019,6 +6112,9 @@ const init = async () => { })); }; const capitalizeFirst = (s) => s.charAt(0).toUpperCase() + s.slice(1); + // Set to compose options when we were opened with a mailto URL. We open the + // compose window after we received the "start" message with our addresses. + let openComposeOptions; const connect = async (isreconnect) => { connectionElem.classList.toggle('loading', true); dom._kids(connectionElem); @@ -6037,6 +6133,18 @@ const init = async () => { showNotConnected(); return; } + const h = decodeURIComponent(window.location.hash); + if (h.startsWith('#compose ')) { + try { + // The compose window is opened when we get the "start" event, which gives us our + // configuration. + openComposeOptions = parseComposeMailto(h.substring('#compose '.length)); + } + catch (err) { + window.alert('Error parsing mailto URL: ' + errmsg(err)); + } + window.location.hash = ''; + } let [searchQuery, msgid, f, notf] = parseLocationHash(mailboxlistView); requestMsgID = msgid; requestFilter = f; @@ -6170,6 +6278,17 @@ const init = async () => { domainAddressConfigs = start.DomainAddressConfigs || {}; rejectsMailbox = start.RejectsMailbox; clearList(); + // If we were opened through a mailto: link, it's time to open the compose window. + if (openComposeOptions) { + (async () => { + // Resolve Q/B-word mime encoding for subject. ../rfc/6068:267 ../rfc/2047:180 + if (openComposeOptions.subject && openComposeOptions.subject.includes('=?')) { + openComposeOptions.subject = await withStatus('Decoding MIME words for subject', client.DecodeMIMEWords(openComposeOptions.subject)); + } + compose(openComposeOptions); + openComposeOptions = undefined; + })(); + } let mailboxName = start.MailboxName; let mb = (start.Mailboxes || []).find(mb => mb.Name === start.MailboxName); if (mb) { diff --git a/webmail/webmail.ts b/webmail/webmail.ts index de18aaa..e4a3be7 100644 --- a/webmail/webmail.ts +++ b/webmail/webmail.ts @@ -814,10 +814,15 @@ const focusPlaceholder = (s: string): any[] => { ] } -// Parse a location hash into search terms (if any), selected message id (if -// any) and filters. -// Optional message id at the end, with ",". -// Otherwise mailbox or 'search '-prefix search string: #Inbox or #Inbox,1 or "#search mb:Inbox" or "#search mb:Inbox,1" +// Parse a location hash, with either mailbox or search terms, and optional +// selected message id. The special "#compose " hash, used for handling +// "mailto:"-links, must be handled before calling this function. +// +// Examples: +// #Inbox +// #Inbox,1 +// #search mb:Inbox +// #search mb:Inbox,1 const parseLocationHash = (mailboxlistView: MailboxlistView): [string | undefined, number, api.Filter, api.NotFilter] => { let hash = decodeURIComponent((window.location.hash || '#').substring(1)) const m = hash.match(/,([0-9]+)$/) @@ -1163,6 +1168,36 @@ const cmdHelp = async () => { cmdHelp() }) ), + dom.div( + style({marginTop: '2ex'}), + 'To start composing a message when opening a "mailto:" link, register this application with your browser/system. ', + dom.clickbutton('Register', attr.title('In most browsers, registering is only allowed on HTTPS URLs. Your browser may ask for confirmation. If nothing appears to happen, the registration may already have been present.'), function click() { + if (!window.navigator.registerProtocolHandler) { + window.alert('Registering a protocol handler ("mailto:") is not supported by your browser.') + return + } + try { + window.navigator.registerProtocolHandler('mailto', '#compose %s') + } catch (err) { + window.alert('Error registering "mailto:" protocol handler: '+errmsg(err)) + } + }), + ' ', + dom.clickbutton('Unregister', attr.title('Not all browsers implement unregistering via JavaScript.'), function click() { + // Not supported on firefox at the time of writing, and the signature is not in the types. + if (!(window.navigator as any).unregisterProtocolHandler) { + window.alert('Unregistering a protocol handler ("mailto:") via JavaScript is not supported by your browser. See your browser settings to unregister.') + return + } + try { + (window.navigator as any).unregisterProtocolHandler('mailto', '#compose %s') + } catch (err) { + window.alert('Error unregistering "mailto:" protocol handler: '+errmsg(err)) + return + } + window.alert('"mailto:" protocol handler unregistered.') + }), + ), dom.div(style({marginTop: '2ex'}), 'Mox is open source email server software, this is version '+moxversion+'. Feedback, including bug reports, is appreciated! ', link('https://github.com/mjl-/mox/issues/new'), '.'), ), ), @@ -5431,6 +5466,33 @@ const newSearchView = (searchbarElem: HTMLInputElement, mailboxlistView: Mailbox return searchView } +// parse the "mailto:..." part (already decoded) of a "#compose mailto:..." url hash. +const parseComposeMailto = (mailto: string): ComposeOptions => { + const u = new URL(mailto) + + const addresses = (s: string) => s.split(',').filter(s => !!s) + const opts: ComposeOptions = {} + opts.to = addresses(u.pathname).map(s => decodeURIComponent(s)) + for (const [xk, v] of new URLSearchParams(u.search)) { + const k = xk.toLowerCase() + if (k === 'to') { + opts.to = [...opts.to, ...addresses(v)] + } else if (k === 'cc') { + opts.cc = [...(opts.cc || []), ...addresses(v)] + } else if (k === 'bcc') { + opts.bcc = [...(opts.bcc || []), ...addresses(v)] + } else if (k === 'subject') { + // q/b-word encoding is allowed, we let the server decode when we start composoing, + // only if needed. ../rfc/6068:267 + opts.subject = v + } else if (k === 'body') { + opts.body = v + } + // todo: we ignore other headers for now. we should handle in-reply-to and references at some point. but we don't allow any custom headers at the time of writing. + } + return opts +} + // Functions we pass to various views, to access functionality encompassing all views. type requestNewView = (clearMsgID: boolean, filterOpt?: api.Filter, notFilterOpt?: api.NotFilter) => Promise type updatePageTitle = () => void @@ -6253,7 +6315,34 @@ const init = async () => { checkMsglistWidth() }) - window.addEventListener('hashchange', async () => { + window.addEventListener('hashchange', async (e: HashChangeEvent) => { + const hash = decodeURIComponent(window.location.hash) + if (hash.startsWith('#compose ')) { + try { + const opts = parseComposeMailto(hash.substring('#compose '.length)) + + // Restore previous hash. + if (e.oldURL) { + const ou = new URL(e.oldURL) + window.location.hash = ou.hash + } else { + window.location.hash = '' + } + + (async () => { + // Resolve Q/B-word mime encoding for subject. ../rfc/6068:267 ../rfc/2047:180 + if (opts.subject && opts.subject.includes('=?')) { + opts.subject = await withStatus('Decoding MIME words for subject', client.DecodeMIMEWords(opts.subject)) + } + compose(opts) + })() + } catch (err) { + window.alert('Error parsing compose mailto URL: '+errmsg(err)) + window.location.hash = '' + } + return + } + const [search, msgid, f, notf] = parseLocationHash(mailboxlistView) requestMsgID = msgid @@ -6317,6 +6406,10 @@ const init = async () => { const capitalizeFirst = (s: string) => s.charAt(0).toUpperCase() + s.slice(1) + // Set to compose options when we were opened with a mailto URL. We open the + // compose window after we received the "start" message with our addresses. + let openComposeOptions: ComposeOptions | undefined + const connect = async (isreconnect: boolean) => { connectionElem.classList.toggle('loading', true) dom._kids(connectionElem) @@ -6337,6 +6430,18 @@ const init = async () => { return } + const h = decodeURIComponent(window.location.hash) + if (h.startsWith('#compose ')) { + try { + // The compose window is opened when we get the "start" event, which gives us our + // configuration. + openComposeOptions = parseComposeMailto(h.substring('#compose '.length)) + } catch (err) { + window.alert('Error parsing mailto URL: '+errmsg(err)) + } + window.location.hash = '' + } + let [searchQuery, msgid, f, notf] = parseLocationHash(mailboxlistView) requestMsgID = msgid requestFilter = f @@ -6479,6 +6584,18 @@ const init = async () => { clearList() + // If we were opened through a mailto: link, it's time to open the compose window. + if (openComposeOptions) { + (async () => { + // Resolve Q/B-word mime encoding for subject. ../rfc/6068:267 ../rfc/2047:180 + if (openComposeOptions.subject && openComposeOptions.subject.includes('=?')) { + openComposeOptions.subject = await withStatus('Decoding MIME words for subject', client.DecodeMIMEWords(openComposeOptions.subject)) + } + compose(openComposeOptions) + openComposeOptions = undefined + })() + } + let mailboxName = start.MailboxName let mb = (start.Mailboxes || []).find(mb => mb.Name === start.MailboxName) if (mb) {