mirror of
https://github.com/mjl-/mox.git
synced 2024-12-26 16:33:47 +03:00
webmail: implement registering and handling "mailto:" links
to start composing a message. the help popup now has a button to register the "mailto:" links with the mox webmail (typically only works over https, not all browsers support it). the mailto links are specified in 6068. we support the to/cc/bcc/subject/body parameters. other parameters should be seen as custom headers, but we don't support messages with custom headers at all at the moment, so we ignore them. we now also turn text of the form "mailto:user@host" into a clickable link (will not be too common). we could be recognizing any "x@x.x" as email address and make them clickable in the future. thanks to Hans-Jörg for explaining this functionality.
This commit is contained in:
parent
f3bf348214
commit
ee1db2dde7
9 changed files with 309 additions and 20 deletions
|
@ -10,7 +10,7 @@ Each tab-separated row has:
|
||||||
- RFC title
|
- RFC title
|
||||||
|
|
||||||
If the support status column value starts with a minus, it isn't included on
|
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
|
- Yes, support is deemed complete
|
||||||
- Partial, support is partial, more work can be done
|
- Partial, support is partial, more work can be done
|
||||||
- Roadmap, no support, but it is planned
|
- 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
|
3339 -? - Date and Time on the Internet: Timestamps
|
||||||
3986 -? - Uniform Resource Identifier (URI): Generic Syntax
|
3986 -? - Uniform Resource Identifier (URI): Generic Syntax
|
||||||
5617 -? - (Historic) DomainKeys Identified Mail (DKIM) Author Domain Signing Practices (ADSP)
|
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
|
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
|
7817 -? - Updated Transport Layer Security (TLS) Server Identity Check Procedure for Email-Related Protocols
|
||||||
|
|
||||||
|
|
|
@ -1782,6 +1782,13 @@ func recipientSecurity(ctx context.Context, resolver dns.Resolver, messageAddres
|
||||||
return rs, nil
|
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 {
|
func slicesAny[T any](l []T) []any {
|
||||||
r := make([]any, len(l))
|
r := make([]any, len(l))
|
||||||
for i, v := range l {
|
for i, v := range l {
|
||||||
|
|
|
@ -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",
|
"Name": "SSETypes",
|
||||||
"Docs": "SSETypes exists to ensure the generated API contains the types, for use in SSE events.",
|
"Docs": "SSETypes exists to ensure the generated API contains the types, for use in SSE events.",
|
||||||
|
|
|
@ -848,6 +848,15 @@ export class Client {
|
||||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as RecipientSecurity
|
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<string> {
|
||||||
|
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.
|
// 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]> {
|
async SSETypes(): Promise<[EventStart, EventViewErr, EventViewReset, EventViewMsgs, EventViewChanges, ChangeMsgAdd, ChangeMsgRemove, ChangeMsgFlags, ChangeMsgThread, ChangeMailboxRemove, ChangeMailboxAdd, ChangeMailboxRename, ChangeMailboxCounts, ChangeMailboxSpecialUse, ChangeMailboxKeywords, Flags]> {
|
||||||
const fn: string = "SSETypes"
|
const fn: string = "SSETypes"
|
||||||
|
|
|
@ -27,7 +27,7 @@ const join = (l: any, efn: () => any): any[] => {
|
||||||
// interpunction moved to the next string instead.
|
// interpunction moved to the next string instead.
|
||||||
const addLinks = (text: string): (HTMLAnchorElement | string)[] => {
|
const addLinks = (text: string): (HTMLAnchorElement | string)[] => {
|
||||||
// todo: look at ../rfc/3986 and fix up regexp. we should probably accept utf-8.
|
// 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 = []
|
const r = []
|
||||||
while (text.length > 0) {
|
while (text.length > 0) {
|
||||||
const l = re.exec(text)
|
const l = re.exec(text)
|
||||||
|
@ -50,7 +50,7 @@ const addLinks = (text: string): (HTMLAnchorElement | string)[] => {
|
||||||
url = url.substring(0, url.length-1)
|
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
|
return r
|
||||||
}
|
}
|
||||||
|
|
|
@ -576,6 +576,14 @@ var api;
|
||||||
const params = [messageAddressee];
|
const params = [messageAddressee];
|
||||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
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.
|
// SSETypes exists to ensure the generated API contains the types, for use in SSE events.
|
||||||
async SSETypes() {
|
async SSETypes() {
|
||||||
const fn = "SSETypes";
|
const fn = "SSETypes";
|
||||||
|
@ -961,7 +969,7 @@ const join = (l, efn) => {
|
||||||
// interpunction moved to the next string instead.
|
// interpunction moved to the next string instead.
|
||||||
const addLinks = (text) => {
|
const addLinks = (text) => {
|
||||||
// todo: look at ../rfc/3986 and fix up regexp. we should probably accept utf-8.
|
// 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 = [];
|
const r = [];
|
||||||
while (text.length > 0) {
|
while (text.length > 0) {
|
||||||
const l = re.exec(text);
|
const l = re.exec(text);
|
||||||
|
@ -985,7 +993,7 @@ const addLinks = (text) => {
|
||||||
url = url.substring(0, url.length - 1);
|
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;
|
return r;
|
||||||
};
|
};
|
||||||
|
|
|
@ -576,6 +576,14 @@ var api;
|
||||||
const params = [messageAddressee];
|
const params = [messageAddressee];
|
||||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
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.
|
// SSETypes exists to ensure the generated API contains the types, for use in SSE events.
|
||||||
async SSETypes() {
|
async SSETypes() {
|
||||||
const fn = "SSETypes";
|
const fn = "SSETypes";
|
||||||
|
@ -961,7 +969,7 @@ const join = (l, efn) => {
|
||||||
// interpunction moved to the next string instead.
|
// interpunction moved to the next string instead.
|
||||||
const addLinks = (text) => {
|
const addLinks = (text) => {
|
||||||
// todo: look at ../rfc/3986 and fix up regexp. we should probably accept utf-8.
|
// 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 = [];
|
const r = [];
|
||||||
while (text.length > 0) {
|
while (text.length > 0) {
|
||||||
const l = re.exec(text);
|
const l = re.exec(text);
|
||||||
|
@ -985,7 +993,7 @@ const addLinks = (text) => {
|
||||||
url = url.substring(0, url.length - 1);
|
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;
|
return r;
|
||||||
};
|
};
|
||||||
|
|
|
@ -576,6 +576,14 @@ var api;
|
||||||
const params = [messageAddressee];
|
const params = [messageAddressee];
|
||||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
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.
|
// SSETypes exists to ensure the generated API contains the types, for use in SSE events.
|
||||||
async SSETypes() {
|
async SSETypes() {
|
||||||
const fn = "SSETypes";
|
const fn = "SSETypes";
|
||||||
|
@ -961,7 +969,7 @@ const join = (l, efn) => {
|
||||||
// interpunction moved to the next string instead.
|
// interpunction moved to the next string instead.
|
||||||
const addLinks = (text) => {
|
const addLinks = (text) => {
|
||||||
// todo: look at ../rfc/3986 and fix up regexp. we should probably accept utf-8.
|
// 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 = [];
|
const r = [];
|
||||||
while (text.length > 0) {
|
while (text.length > 0) {
|
||||||
const l = re.exec(text);
|
const l = re.exec(text);
|
||||||
|
@ -985,7 +993,7 @@ const addLinks = (text) => {
|
||||||
url = url.substring(0, url.length - 1);
|
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;
|
return r;
|
||||||
};
|
};
|
||||||
|
@ -1875,10 +1883,15 @@ const focusPlaceholder = (s) => {
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
// Parse a location hash into search terms (if any), selected message id (if
|
// Parse a location hash, with either mailbox or search terms, and optional
|
||||||
// any) and filters.
|
// selected message id. The special "#compose " hash, used for handling
|
||||||
// Optional message id at the end, with ",<num>".
|
// "mailto:"-links, must be handled before calling this function.
|
||||||
// Otherwise mailbox or 'search '-prefix search string: #Inbox or #Inbox,1 or "#search mb:Inbox" or "#search mb:Inbox,1"
|
//
|
||||||
|
// Examples:
|
||||||
|
// #Inbox
|
||||||
|
// #Inbox,1
|
||||||
|
// #search mb:Inbox
|
||||||
|
// #search mb:Inbox,1
|
||||||
const parseLocationHash = (mailboxlistView) => {
|
const parseLocationHash = (mailboxlistView) => {
|
||||||
let hash = decodeURIComponent((window.location.hash || '#').substring(1));
|
let hash = decodeURIComponent((window.location.hash || '#').substring(1));
|
||||||
const m = hash.match(/,([0-9]+)$/);
|
const m = hash.match(/,([0-9]+)$/);
|
||||||
|
@ -2144,7 +2157,32 @@ const cmdHelp = async () => {
|
||||||
settingsPut({ ...settings, showShortcuts: true });
|
settingsPut({ ...settings, showShortcuts: true });
|
||||||
remove();
|
remove();
|
||||||
cmdHelp();
|
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
|
// 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
|
// 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;
|
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 () => {
|
const init = async () => {
|
||||||
let connectionElem; // SSE connection status/error. Empty when connected.
|
let connectionElem; // SSE connection status/error. Empty when connected.
|
||||||
let layoutElem; // Select dropdown for layout.
|
let layoutElem; // Select dropdown for layout.
|
||||||
|
@ -5968,7 +6035,33 @@ const init = async () => {
|
||||||
}
|
}
|
||||||
checkMsglistWidth();
|
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);
|
const [search, msgid, f, notf] = parseLocationHash(mailboxlistView);
|
||||||
requestMsgID = msgid;
|
requestMsgID = msgid;
|
||||||
if (search) {
|
if (search) {
|
||||||
|
@ -6019,6 +6112,9 @@ const init = async () => {
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
const capitalizeFirst = (s) => s.charAt(0).toUpperCase() + s.slice(1);
|
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) => {
|
const connect = async (isreconnect) => {
|
||||||
connectionElem.classList.toggle('loading', true);
|
connectionElem.classList.toggle('loading', true);
|
||||||
dom._kids(connectionElem);
|
dom._kids(connectionElem);
|
||||||
|
@ -6037,6 +6133,18 @@ const init = async () => {
|
||||||
showNotConnected();
|
showNotConnected();
|
||||||
return;
|
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);
|
let [searchQuery, msgid, f, notf] = parseLocationHash(mailboxlistView);
|
||||||
requestMsgID = msgid;
|
requestMsgID = msgid;
|
||||||
requestFilter = f;
|
requestFilter = f;
|
||||||
|
@ -6170,6 +6278,17 @@ const init = async () => {
|
||||||
domainAddressConfigs = start.DomainAddressConfigs || {};
|
domainAddressConfigs = start.DomainAddressConfigs || {};
|
||||||
rejectsMailbox = start.RejectsMailbox;
|
rejectsMailbox = start.RejectsMailbox;
|
||||||
clearList();
|
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 mailboxName = start.MailboxName;
|
||||||
let mb = (start.Mailboxes || []).find(mb => mb.Name === start.MailboxName);
|
let mb = (start.Mailboxes || []).find(mb => mb.Name === start.MailboxName);
|
||||||
if (mb) {
|
if (mb) {
|
||||||
|
|
|
@ -814,10 +814,15 @@ const focusPlaceholder = (s: string): any[] => {
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse a location hash into search terms (if any), selected message id (if
|
// Parse a location hash, with either mailbox or search terms, and optional
|
||||||
// any) and filters.
|
// selected message id. The special "#compose " hash, used for handling
|
||||||
// Optional message id at the end, with ",<num>".
|
// "mailto:"-links, must be handled before calling this function.
|
||||||
// Otherwise mailbox or 'search '-prefix search string: #Inbox or #Inbox,1 or "#search mb:Inbox" or "#search mb:Inbox,1"
|
//
|
||||||
|
// Examples:
|
||||||
|
// #Inbox
|
||||||
|
// #Inbox,1
|
||||||
|
// #search mb:Inbox
|
||||||
|
// #search mb:Inbox,1
|
||||||
const parseLocationHash = (mailboxlistView: MailboxlistView): [string | undefined, number, api.Filter, api.NotFilter] => {
|
const parseLocationHash = (mailboxlistView: MailboxlistView): [string | undefined, number, api.Filter, api.NotFilter] => {
|
||||||
let hash = decodeURIComponent((window.location.hash || '#').substring(1))
|
let hash = decodeURIComponent((window.location.hash || '#').substring(1))
|
||||||
const m = hash.match(/,([0-9]+)$/)
|
const m = hash.match(/,([0-9]+)$/)
|
||||||
|
@ -1163,6 +1168,36 @@ const cmdHelp = async () => {
|
||||||
cmdHelp()
|
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'), '.'),
|
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
|
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.
|
// Functions we pass to various views, to access functionality encompassing all views.
|
||||||
type requestNewView = (clearMsgID: boolean, filterOpt?: api.Filter, notFilterOpt?: api.NotFilter) => Promise<void>
|
type requestNewView = (clearMsgID: boolean, filterOpt?: api.Filter, notFilterOpt?: api.NotFilter) => Promise<void>
|
||||||
type updatePageTitle = () => void
|
type updatePageTitle = () => void
|
||||||
|
@ -6253,7 +6315,34 @@ const init = async () => {
|
||||||
checkMsglistWidth()
|
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)
|
const [search, msgid, f, notf] = parseLocationHash(mailboxlistView)
|
||||||
|
|
||||||
requestMsgID = msgid
|
requestMsgID = msgid
|
||||||
|
@ -6317,6 +6406,10 @@ const init = async () => {
|
||||||
|
|
||||||
const capitalizeFirst = (s: string) => s.charAt(0).toUpperCase() + s.slice(1)
|
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) => {
|
const connect = async (isreconnect: boolean) => {
|
||||||
connectionElem.classList.toggle('loading', true)
|
connectionElem.classList.toggle('loading', true)
|
||||||
dom._kids(connectionElem)
|
dom._kids(connectionElem)
|
||||||
|
@ -6337,6 +6430,18 @@ const init = async () => {
|
||||||
return
|
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)
|
let [searchQuery, msgid, f, notf] = parseLocationHash(mailboxlistView)
|
||||||
requestMsgID = msgid
|
requestMsgID = msgid
|
||||||
requestFilter = f
|
requestFilter = f
|
||||||
|
@ -6479,6 +6584,18 @@ const init = async () => {
|
||||||
|
|
||||||
clearList()
|
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 mailboxName = start.MailboxName
|
||||||
let mb = (start.Mailboxes || []).find(mb => mb.Name === start.MailboxName)
|
let mb = (start.Mailboxes || []).find(mb => mb.Name === start.MailboxName)
|
||||||
if (mb) {
|
if (mb) {
|
||||||
|
|
Loading…
Reference in a new issue