forked from MyOutDeskLLC/auto-bcc
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathgmail.ts
537 lines (475 loc) · 21.7 KB
/
gmail.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
const SELECTOR_PRIMARY_COMPOSER_FORM = "form.bAs";
const SELECTOR_FOR_FROM_SPAN = '.az2.az4.L3 span';
const SELECTOR_FOR_BCC_SPAN = ".aB.gQ.pB";
const SELECTOR_FOR_CC_SPAN = ".aB.gQ.pE";
const EMAIL_REGEX = /([a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*)/;
import "./icons/gray-scale-orange-square-mail.png"
const DEFAULT_OPTIONS = {
offByDefault: false
}
class GmailAutoBccHandler {
public debugMode: boolean;
public ccEmails: string;
public bccEmails: string;
public knownForms: string[];
public formsEnabled: Record<string, any>;
public observerMap: Record<string, any>;
public observer: any;
public rules: Record<string, any>;
public options: Record<string, any>;
constructor() {
this.debugMode = true;
this.ccEmails = "";
this.bccEmails = "";
this.knownForms = [];
this.formsEnabled = {};
this.observerMap = {};
this.observer = null;
this.rules = {};
this.options = {};
// Get initial rules and hookup observers
this.debug("Retrieving email rules from local storage.");
this.getRulesFromStorage();
this.debug("Retrieving options from storage.");
this.getOptionsFromStorage();
this.debug("Creating an email form observer.");
this.createEmailFormObserver();
this.debug("Creating an observer for garbage collection.");
this.createGlobalObserver();
this.debug("Adding a listener for changes to the rules.");
chrome.storage.onChanged.addListener(() => {
this.getRulesFromStorage();
});
}
/**
* This function will discover the logged-in user from the window title
*/
discoverLoggedInUser = () => {
let matches = EMAIL_REGEX.exec(document.title);
if (matches !== null) {
// The reason behind this is to display the latest email correctly. If an email thread has multiple emails
// with the same topic, displaying any email other than the last one could result in an error.
return matches[matches.length - 1];
}
this.debug(`Failed to discover logged-in user based on current title ${document.title}`);
// If we cannot find it, then im not sure?
return null;
};
/**
* This function will discover the current "from" address in the email form
* if it exists. If it does not exist, then it will return the logged-in user.
*/
discoverCurrentSender = () => {
// Find the span element containing the email address using the defined
// selector. This span element is only present when the user has
// multiple "send as" addresses configured.
const fromField = document.querySelector(SELECTOR_FOR_FROM_SPAN);
if (fromField && fromField.textContent) {
// Extracting the email address using regex
const emailMatch = fromField.textContent.match(EMAIL_REGEX);
if (emailMatch) {
return emailMatch[0]; // Return the extracted email address
} else {
this.debug(`Failed to extract sender email from: ${fromField.textContent}`);
}
}
this.debug(`Failed to discover current sender, returning logged-in user`);
return this.discoverLoggedInUser();
};
/**
* This is just a helper function to print debug statements into the console
*
* @param output
*/
debug = (...output: any[]) => {
if (!this.debugMode) {
return;
}
output.forEach(item => {
if (typeof item === "string") {
console.log(`%c${item}`, "background: #222; color: #7dd3fc; padding: 0.375rem;");
} else {
console.log(`%cObject: %o`, "background: #222; color: #7dd3fc", item);
}
});
};
getRulesFromStorage = () => {
chrome.storage.local.get("rules", (data: Record<string, any>) => {
if (!data.rules) {
this.rules = {};
return;
}
this.rules = data.rules;
});
};
getOptionsFromStorage = () => {
chrome.storage.local.get("options", (data: Record<string, any>) => {
if (!data.options) {
this.options = DEFAULT_OPTIONS;
return;
}
this.options = data.options;
});
}
createIgnoreEmailButton = (formId: string) => {
let tbody = document.getElementById(formId)?.parentElement?.parentElement?.parentElement;
if (!tbody) {
return;
}
let tr = document.createElement("tr");
let td = document.createElement("td");
let button = document.createElement("button");
let image = new Image();
let span = document.createElement("span");
span.innerText = this.options.offByDefault === false ? "AutoBCC Enabled" : "AutoBCC Disabled";
span.style.marginLeft = '0.25rem';
span.style.fontSize = '0.75rem';
image.src = this.options.offByDefault === false ? chrome.runtime.getURL("src/icons/orange-square-mail.png") : chrome.runtime.getURL("src/icons/gray-scale-orange-square-mail.png");
image.style.width = "1rem";
image.style.height = "1rem";
button.style.borderWidth = "0px";
button.style.cursor = "pointer";
button.style.marginTop = "0.5rem";
button.style.borderRadius = "0.25rem";
button.style.paddingTop = "0.25rem";
button.style.paddingRight = "0.25rem";
button.style.paddingLeft = "0.25rem";
button.style.display = "flex";
button.style.alignItems = "center";
button.style.justifyContent = "center";
button.setAttribute("form-id", formId);
button.append(image);
button.append(span);
button.addEventListener("click", () => {
if (this.formsEnabled[formId].disabled === true) {
let img = button.firstChild as HTMLImageElement;
img.src = chrome.runtime.getURL("src/icons/orange-square-mail.png");
if (button.children[1]) {
//@ts-ignore
button.children[1].innerText = "AutoBCC Enabled";
}
this.formsEnabled[formId].disabled = false;
this.debug('setting to FALSE')
//Add recipients to the form when the button is clicked
let formElement: HTMLElement | null = document.getElementById(formId);
this.scanForCardsUnderNode(formElement).forEach(card => {
this.updateCcAndBccRecipients(card.dataset.hovercardId, formElement);
})
} else {
this.debug(button.firstChild);
let img = button.firstChild as HTMLImageElement
img.src = chrome.runtime.getURL("src/icons/gray-scale-orange-square-mail.png");
//@ts-ignore
button.children[1].innerText = "AutoBCC Disabled";
this.formsEnabled[formId].disabled = true;
this.debug('setting to TRUE')
}
});
td.append(button);
//Google seems to be applying some styling to the first element in the table cell (td) after the row is added
// to the DOM. To avoid this issue, we add the table row (tr) to the DOM with the desired td element, and then
// add the td to the row.
let ghettoHookTdForGoogle = document.createElement("td");
ghettoHookTdForGoogle.classList.add("aoY");
tr.append(td);
tbody.prepend(tr);
//@ts-ignore
tbody.childNodes[0].prepend(ghettoHookTdForGoogle);
};
/**
Creates an interval to ensure old observers are cleaned up. If a form no longer exists,
the corresponding observer will be disconnected to prevent unnecessary processing.
*/
createGlobalObserver() {
this.observer = setInterval(() => {
// Loop through each known form ID and filter out any forms that no longer exist on the page
this.knownForms = this.knownForms.filter(formId => {
if (!document.getElementById(formId)) {
// If the form is missing, disconnect its observer (if it exists), delete its entry in the
// formsEnabled object, and return false to remove it from the knownForms array
if (this.observerMap[formId]) {
this.debug(`Disconnecting missing form: ${formId}`);
this.observerMap[formId].disconnect();
delete (this.formsEnabled[formId])
}
return false;
}
if (!Object.keys(this.formsEnabled).includes(formId)) {
this.formsEnabled[formId] = {
disabled: this.options.offByDefault === true,
};
this.createIgnoreEmailButton(formId);
}
// Form is still on the page, do nothing here
return true;
});
}, 1000);
}
/**
* This code utilizes a Mutation Observer to scan the entire document for new email forms that may appear.
*/
createEmailFormObserver = () => {
this.observer = new MutationObserver(this.examineInsertedElements);
this.observer.observe(document.body, {
childList: true,
attributes: false,
subtree: true,
});
};
/**
* This function detects the addition of any forms on the page, such as those generated by clicking "compose" or
* "reply."
*
* @param elementsInserted
*/
examineInsertedElements = (elementsInserted: any[]) => {
elementsInserted.forEach(mutation => {
mutation.addedNodes.forEach((node: any) => {
if (!node || typeof node.querySelector !== "function") {
return;
}
if (node.querySelector(SELECTOR_PRIMARY_COMPOSER_FORM)) {
this.connectToNewForms();
}
});
});
};
/**
* Once the mutation observer detects a new form, this function establishes a connection to it and adds an observer
* to the recipient field. Additionally, it checks if any recipients are already declared (such as from a draft
* email) and updates the corresponding fields accordingly.
*/
connectToNewForms() {
// Get all forms that match the primary composer selector
const allForms: NodeListOf<HTMLElement> = document.querySelectorAll(SELECTOR_PRIMARY_COMPOSER_FORM);
// Loop through each form and connect to new forms if they are not already known
allForms.forEach((formElement: HTMLElement) => {
if (this.knownForms.includes(formElement.id)) {
return;
}
this.knownForms.push(formElement.id);
this.debug(`Connecting to new form: ${formElement.id}`);
// Add a timeout to wait for recipient inputs to load before observing changes
setTimeout(() => {
this.checkExistingRecipients(formElement);
this.observeRecipientCards(formElement);
this.observeRecipientInput(formElement);
}, 500);
});
}
/**
* For drafts and replies, we need to check if anyone is already getting this email
*
* @param formElement
*/
checkExistingRecipients(formElement: HTMLElement) {
// Initialize a variable to store the found element
let foundElement = null;
// Loop through each div element with class "afx" and look for a search field
formElement.querySelectorAll("div.afx").forEach(divElement => {
if (divElement.ariaLabel && divElement.ariaLabel.toLowerCase().startsWith(`search field`)) {
this.debug("Located search field, looking for nearby input");
// If a search field is found, set the found element to its input field
foundElement = divElement.querySelector("input");
}
});
// If a found element exists, scan for existing recipients under it
if (foundElement) {
this.debug("Scanning for existing recipients under search field", foundElement);
this.scanForCardsUnderNode(foundElement).forEach(item => {
this.debug(`Found Card: ${item.dataset.hovercardId}`)
// Update the CC and BCC recipients for each recipient card found
this.updateCcAndBccRecipients(item.dataset.hovercardId, formElement);
});
}
}
/**
* After a new recipient is added and the enter key is pressed, the recipient's email is moved from the input field
* to a hoverable card. Therefore, when sending an email, replying to one, or continuing from a draft, we need to
* locate these hoverable cards and extract the email addresses from them.
*
* @param node
* @returns {*[]}
*/
scanForCardsUnderNode(node: any) {
this.debug("Scanning for cards under node")
// Find the closest parent row element
let parentRow = node.closest("tr");
// Find all divs under the parent row with the role "option", which contain recipient data
let potentialNearbyCards = parentRow.querySelectorAll("div[role=option]");
// Filter out any potential cards that don't have a dataset or hovercardId
return [...potentialNearbyCards].filter(card => card.dataset && card.dataset.hovercardId);
}
observeRecipientInput = (formElement: HTMLElement) => {
this.locateProperRecipientInputField(formElement);
};
locateProperRecipientInputField(formElement: HTMLElement) {
// Loop through each span element and look for the "To - Select Contacts" aria-label
formElement.querySelectorAll("span").forEach(spanElement => {
if (spanElement.ariaLabel && spanElement.ariaLabel.toLowerCase().startsWith("to - select contacts")) {
// If the proper span element is found, set the proper email input field
let properEmailInputField = spanElement?.parentElement?.parentElement?.parentElement?.querySelector("input");
this.debug("Email To Field Input: ", properEmailInputField);
if (!properEmailInputField) {
return;
}
// Add an event listener to the input field's blur event to update the CC and BCC recipients
properEmailInputField.addEventListener("blur", (e: FocusEvent) => {
this.debug("Blur event fired.");
this.scanForCardsUnderNode(formElement).forEach(card => {
this.debug(`Found Card: ${card.dataset.hovercardId}`)
this.updateCcAndBccRecipients(card.dataset.hovercardId, formElement);
})
//@ts-ignore
e?.target?.value?.split(",").forEach((recipient: string) => {
this.debug(`Found raw email: ${recipient}`)
this.updateCcAndBccRecipients(recipient, formElement);
});
});
// Add an event listener to the input field's keydown event to update the CC and BCC recipients when
// the Escape key is pressed
properEmailInputField.addEventListener("keydown", (e) => {
if (e.code !== "Escape") {
return;
}
setTimeout(() => {
this.debug("Firing esc handler to check emails");
//@ts-ignore
e?.target?.value?.split(",").forEach((recipient: string) => {
this.updateCcAndBccRecipients(recipient, formElement);
});
}, 350);
});
}
});
}
/**
* This function sets up an observer on the intended recipients block to track the email addresses being added or
* removed from the block.
*
* @param formElement
*/
observeRecipientCards = (formElement: HTMLElement) => {
const recipientChanged = (mutationList: any) => {
for (const mutation of mutationList) {
for (const addedNode of mutation.addedNodes) {
if (addedNode.role === "option" && addedNode.dataset && addedNode.dataset.hovercardId) {
this.debug("to recipient change found, updating cc and bcc recipients", addedNode.dataset);
this.updateCcAndBccRecipients(addedNode.dataset.hovercardId, formElement);
}
}
}
}
const observer = new MutationObserver(recipientChanged);
observer.observe(formElement, {
attributes: false,
childList: true,
subtree: true,
});
this.observerMap[formElement.id] = observer;
};
/**
* This function inserts the correct CC and BCC recipients into the respective fields according to the defined
* rules.
*
* @param recipient
* @param formElement
*/
updateCcAndBccRecipients = (recipient: string, formElement: HTMLElement | null) => {
if (!formElement) {
return;
}
// Store the currently focused element, so it can be refocused later
let currentFocus = document.querySelector<HTMLElement>(":focus");
this.debug(this.formsEnabled);
if (!this.formsEnabled[formElement.id]) {
this.debug("Form not found. Trying again in 250ms");
setTimeout(() => {
this.updateCcAndBccRecipients(recipient, formElement);
}, 250);
return;
}
// Check if email rules are disabled for this form
if (this.formsEnabled[formElement.id] && this.formsEnabled[formElement.id].disabled === true) {
this.debug('email rules disabled for form. returning early');
return;
}
// Open the CC and BCC fields
formElement.querySelector<HTMLElement>(SELECTOR_FOR_CC_SPAN)?.click();
formElement.querySelector<HTMLElement>(SELECTOR_FOR_BCC_SPAN)?.click();
// Check if there are any email rules defined
if (Object.keys(this.rules).length < 1) {
return;
}
// Determine the current sender and target domain
let currentSender = this.discoverCurrentSender();
let targetDomain = recipient.split("@")[1];
// Check if there are any email rules available for this sender and domain
if (!currentSender || !this.rules[currentSender] || !targetDomain || this.rules[currentSender].excludedDomains.includes(targetDomain)) {
this.debug("no rules available for email sender.");
return;
}
// Apply the email rules to the BCC and CC fields
this.debug("current sender: " + currentSender);
this.autofillField(formElement, this.rules[currentSender].bccEmails, "bcc");
this.autofillField(formElement, this.rules[currentSender].ccEmails, "cc");
// Refocus the previously focused element
if (currentFocus) {
currentFocus.focus();
}
};
mergeArraysWithNoDuplicates = (array1: any[], array2: any[]) => {
let newArray = [...array1];
array2.forEach(item => {
if (!newArray.includes(item)) {
newArray.push(item);
}
});
return newArray;
};
/**
* We decided to use aria labels instead of classes to locate elements since they are more accessible and less
* likely to change frequently. We scan for the cards and emails using this as an anchor point.
*
* @param formElement
* @param emailList
* @param context
*/
autofillField = (formElement: HTMLElement, emailList: string[], context: string) => {
if (emailList.length < 1) {
this.debug("email list empty", context);
return;
}
let validEmails = emailList.filter(email => {
return EMAIL_REGEX.test(email);
});
if (validEmails.length === 0) {
return;
}
formElement.querySelectorAll("span").forEach((spanElement: HTMLSpanElement) => {
if (spanElement.ariaLabel && spanElement.ariaLabel.toLowerCase().startsWith(`${context} -`)) {
this.debug("found proper nearby span to autofill against", spanElement);
let properEmailInputField = spanElement?.parentElement?.parentElement?.querySelector("input");
if (!properEmailInputField) {
return;
}
this.debug("found proper input field to use", properEmailInputField);
let existingEmails = properEmailInputField.value;
let emailsInsideRegularInput = properEmailInputField.value.split(",");
let emailsCommittedAsCards: string[] = [];
this.scanForCardsUnderNode(spanElement).forEach(card => {
this.debug(`Found Card: ${card.dataset.hovercardId}`)
emailsCommittedAsCards.push(card.dataset.hovercardId);
});
let newInputArray = this.mergeArraysWithNoDuplicates(emailsInsideRegularInput, emailList);
let finalInput = newInputArray.filter((item) => {
return item && !emailsCommittedAsCards.includes(item);
});
this.debug(finalInput);
properEmailInputField.value = finalInput.join(",");
this.debug(`updated ${context} recipients to`, emailList, existingEmails);
}
});
};
}
new GmailAutoBccHandler();