Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Handle mailto in argv, support full syntax #288

Open
wants to merge 1 commit into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 104 additions & 39 deletions src/main/account-views/preload/gmail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,11 +198,109 @@ function parseNewMails(feedDocument: Document) {
return newMails
}

function clickElement(selector: string) {
const element = document.querySelector<HTMLDivElement>(selector)
if (element) {
element.click()
const nextTick = async () =>
new Promise((resolve) => {
setTimeout(resolve, 0)
})

async function composeMail(
_event: IpcRendererEvent,
to?: string,
cc?: string | null,
bcc?: string | null,
subject?: string | null,
body?: string | null
) {
// Can't use element ids as selectors since they aren't stable

const composeButton = await elementReady<HTMLDivElement>('div[gh="cm"]', {
stopOnDomReady: false,
timeout: 60000
})
if (!composeButton) throw new Error('No composeButton')
composeButton.click()

// Out of the "to" block because we use the readiness of this element to
// know when we can start filling out the fields
const toElement = await elementReady<HTMLTextAreaElement>(
'textarea[name="to"]',
{
stopOnDomReady: false,
timeout: 60000
}
)
const ccElement = document.querySelector<HTMLTextAreaElement>(
'textarea[name="cc"]'
)
const bccElement = document.querySelector<HTMLTextAreaElement>(
'textarea[name="bcc"]'
)
const subjectElement = document.querySelector<HTMLInputElement>(
'input[name="subjectbox"]'
)
const bodyElement = document.querySelector<HTMLDivElement>(
'div[aria-label="Message Body"]'
)

if (!toElement) throw new Error('No toElement')
if (!ccElement) throw new Error('No ccElement')
if (!bccElement) throw new Error('No bccElement')
if (!subjectElement) throw new Error('No subjectElement')
if (!bodyElement) throw new Error('No bodyElement')

if (to) {
toElement.focus()
toElement.value = to
await nextTick()
}

// Why the nextTick at the end of each block? Because otherwise the
// fields that follow may fail to get focused for some reason. This
// isn't the original comment, see prior commits.

if (cc) {
document
.querySelector<HTMLSpanElement>(
'span[aria-label="Add Cc recipients ‪(Ctrl-Shift-C)‬"]'
)
?.click()
ccElement.focus()
ccElement.value = cc
await nextTick()
}

if (bcc) {
document
.querySelector<HTMLSpanElement>(
'span[aria-label="Add Bcc recipients ‪(Ctrl-Shift-B)‬"]'
)
?.click()
bccElement.focus()
bccElement.value = bcc
await nextTick()
}

if (subject) {
subjectElement.focus()
subjectElement.value = subject
await nextTick()
}

if (body) {
bodyElement.focus()
bodyElement.innerHTML = body
await nextTick()
}

/* eslint-disable no-negated-condition */
if (!to) {
toElement.focus()
} else if (!subject) {
subjectElement.focus()
} else {
bodyElement.focus()
}
/* eslint-enable no-negated-condition */
}

export function initGmail() {
Expand Down Expand Up @@ -253,42 +351,9 @@ export function initGmail() {
}
)

ipcRenderer.on('gmail:compose-mail', async (_event, to?: string) => {
clickElement('div[gh="cm"]')

if (!to) {
return
}

const toElement = await elementReady<HTMLTextAreaElement>(
'textarea[name="to"]',
{
stopOnDomReady: false,
timeout: 60000
}
)

if (!toElement) {
return
}

toElement.focus()
toElement.value = to
ipcRenderer.on('gmail:compose-mail', composeMail)

const subjectElement = document.querySelector<HTMLInputElement>(
'input[name="subjectbox"]'
)

if (!subjectElement) {
return
}

// The subject input can't be focused immediately after
// settings the "to" input value for an unknown reason.
setTimeout(() => {
subjectElement.focus()
}, 200)
})
ipcRenderer.send('gmail:ready')

setInterval(() => {
previousNewMails.clear()
Expand Down
8 changes: 6 additions & 2 deletions src/main/app.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { app } from 'electron'
import config, { ConfigKey } from './config'
import { handleMailto } from './mailto'
import { getMainWindow, showMainWindow } from './main-window'
import { sendToSelectedAccountView } from './account-views'
import { appId } from '../constants'

let isQuittingApp = false
Expand Down Expand Up @@ -36,11 +36,15 @@ export async function initApp() {
}

app.on('second-instance', () => {
if (getMainWindow()) {
handleMailto(argv[argv.length - 1])
}

showMainWindow()
})

app.on('open-url', (_event, mailto) => {
sendToSelectedAccountView('gmail:compose-mail', mailto.split(':')[1])
handleMailto(mailto)

showMainWindow()
})
Expand Down
12 changes: 11 additions & 1 deletion src/main/gmail.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { app, ipcMain, Notification } from 'electron'
import { getAccountIdByViewId } from './account-views'
import { getAccount, selectAccount } from './accounts'
import { getAccount, selectAccount, getSelectedAccount } from './accounts'
import { sendToMainWindow, showMainWindow } from './main-window'
import config, { ConfigKey } from './config'
import { is } from 'electron-util'
import { updateTrayUnreadStatus } from './tray'
import { Mail, UnreadCounts } from '../types'
import { isEnabled as isDoNotDisturbEnabled } from '@sindresorhus/do-not-disturb'
import { handleMailto } from './mailto'

const unreadCounts: UnreadCounts = {}

Expand Down Expand Up @@ -134,4 +135,13 @@ export function handleGmail() {
}
})
}

let isInitialMailtoHandled = false
ipcMain.on('gmail:ready', ({ sender }) => {
if (isInitialMailtoHandled) return
const accountId = getAccountIdByViewId(sender.id)
if (!accountId || accountId !== getSelectedAccount()?.id) return
isInitialMailtoHandled = true
handleMailto(process.argv[process.argv.length - 1])
})
}
33 changes: 33 additions & 0 deletions src/main/mailto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { sendToSelectedAccountView } from './account-views'
import { instanceOfNodeError } from '../utils/type-helpers'

function parse(uri: string) {
try {
return new URL(uri)
} catch (error: unknown) {
if (
instanceOfNodeError(error, TypeError) &&
error.code === 'ERR_INVALID_URL'
) {
return
}

throw error
}
}

const spaceAfterComma = (x: string | null) => x?.replaceAll(',', ', ')

export function handleMailto(uri?: string) {
if (!uri) return // Empty string doesn't cut it either
const x = parse(uri)
if (!x) return
sendToSelectedAccountView(
'gmail:compose-mail',
spaceAfterComma(x.pathname),
spaceAfterComma(x.searchParams.get('cc')),
spaceAfterComma(x.searchParams.get('bcc')),
x.searchParams.get('subject'),
x.searchParams.get('body')
)
}
7 changes: 7 additions & 0 deletions src/utils/type-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// https://dev.to/jdbar/the-problem-with-handling-node-js-errors-in-typescript-and-the-workaround-m64
export function instanceOfNodeError<T extends new (...args: any) => Error>(
value: unknown,
errorType: T
): value is InstanceType<T> & NodeJS.ErrnoException {
return value instanceof errorType
}