Skip to content

Commit e01bfc7

Browse files
author
Matt Diehl
committed
Allow matching message thread details.
Create rules that can match an email thread's details. Thread details include: - label - is_important - is_in_inbox - is_in_priority_inbox - is_in_spam - is_in_trash - is_starred - is_unread - first_message_subject For example, an email thread may already exist in the labels: - ABC - ABC/xyz - def When a new email is received that is for the same thread, the following rule can be used: (thread label ABC/xyz) Or you can check if an email thread is marked 'important': (thread is_important) Or you can check what the original thread subjectline was: (thread first_message_subject /This has no RE: in it/i)
1 parent 3e6fa8d commit e01bfc7

2 files changed

Lines changed: 252 additions & 27 deletions

File tree

Condition.ts

Lines changed: 230 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,26 @@ import Utils from './utils';
2222
const RE_FLAG_PATTERN = /^\/(.*)\/([gimuys]*)$/;
2323

2424
enum ConditionType {
25-
AND, OR, NOT, SUBJECT, FROM, TO, CC, BCC, LIST, SENDER, RECEIVER, BODY, HEADER,
25+
AND, OR, NOT, SUBJECT, FROM, TO, CC, BCC, LIST, SENDER, RECEIVER, BODY, HEADER, THREAD
26+
}
27+
28+
enum ThreadSubType {
29+
FIRST_MESSAGE_SUBJECT, LABEL, IS_STARRED, IS_IMPORTANT, IS_IN_INBOX, IS_IN_PRIORITY_INBOX,
30+
IS_IN_SPAM, IS_IN_TRASH, IS_UNREAD
2631
}
2732

2833
/**
2934
* S expression represents condition in rule.
3035
*
3136
* Syntax:
32-
* CONDITION_EXP := (OPERATOR CONDITION_LIST) | (MATCHER STRING)
37+
* CONDITION_EXP := (OPERATOR CONDITION_LIST) | (MATCHER STRING) |
38+
* (MATCHER_SUBTYPE SUBTYPE_STRING STRING) | (MATCHER_SUBTYPE SUBTYPE_BOOL)
3339
* OPERATOR := and | or | not
34-
* MATCHER := subject | from | to | cc | bcc | list | sender | receiver | content
40+
* MATCHER := subject | from | to | cc | bcc | list | sender | receiver | body
41+
* MATCHER_SUBTYPE := header | thread
42+
* SUBTYPE_STRING := first_message_subject | label | STRING
43+
* SUBTYPE_BOOL := is_starred | is_important | is_in_inbox | is_in_priority_inbox |
44+
* is_in_spam | is_in_trash | is_unread
3545
* CONDITION_LIST := CONDITION_EXP | CONDITION_EXP CONDITION_LIST
3646
*/
3747
export default class Condition {
@@ -87,7 +97,8 @@ export default class Condition {
8797
}
8898

8999
private readonly type: ConditionType;
90-
private readonly subtype: string;
100+
private readonly header: string;
101+
private readonly threadSubtype: ThreadSubType;
91102
private readonly regexp: RegExp;
92103
private readonly sub_conditions: Condition[];
93104

@@ -99,7 +110,6 @@ export default class Condition {
99110
const type_str = condition_str.substring(1, first_space).trim().toUpperCase();
100111
let rest_str = condition_str.substring(first_space + 1, condition_str.length - 1).trim();
101112
this.type = ConditionType[type_str as keyof typeof ConditionType];
102-
this.subtype = "";
103113
switch (this.type) {
104114
case ConditionType.AND:
105115
case ConditionType.OR: {
@@ -123,11 +133,27 @@ export default class Condition {
123133
this.regexp = Condition.parseRegExp(rest_str, condition_str, true);
124134
break;
125135
}
126-
case ConditionType.HEADER: {
136+
case ConditionType.HEADER:
137+
case ConditionType.THREAD: {
127138
const subtype_first_space = rest_str.indexOf(" ");
128-
this.subtype = rest_str.substring(0, subtype_first_space).trim();
129-
rest_str = rest_str.substring(subtype_first_space + 1, rest_str.length - 1).trim();
130-
this.regexp = Condition.parseRegExp(rest_str, condition_str, false);
139+
const subtype = subtype_first_space > 0
140+
? rest_str.substring(0, subtype_first_space).trim()
141+
: rest_str;
142+
let matching_address = true;
143+
if (this.type === ConditionType.HEADER) {
144+
this.header = subtype;
145+
}
146+
if (this.type === ConditionType.THREAD) {
147+
this.threadSubtype = ThreadSubType[subtype.toUpperCase() as keyof typeof ThreadSubType];
148+
if (this.threadSubtype === ThreadSubType.FIRST_MESSAGE_SUBJECT) {
149+
matching_address = false;
150+
}
151+
if (this.threadSubtype === undefined) {
152+
throw `Invalid 'thread' subtype: "${condition_str}"`;
153+
}
154+
}
155+
rest_str = rest_str.substring(subtype_first_space + 1, rest_str.length).trim();
156+
this.regexp = Condition.parseRegExp(rest_str, condition_str, matching_address);
131157
break;
132158
}
133159
case ConditionType.SUBJECT:
@@ -189,12 +215,43 @@ export default class Condition {
189215
return this.regexp.test(message_data.body);
190216
}
191217
case ConditionType.HEADER: {
192-
const headerData = message_data.headers.get(this.subtype);
218+
const headerData = message_data.headers.get(this.header);
193219
if (headerData !== undefined) {
194220
return this.regexp.test(headerData);
195221
}
196222
return false;
197223
}
224+
case ConditionType.THREAD: {
225+
switch (this.threadSubtype) {
226+
case ThreadSubType.IS_IMPORTANT: {
227+
return message_data.thread_is_important;
228+
}
229+
case ThreadSubType.IS_IN_INBOX: {
230+
return message_data.thread_is_in_inbox;
231+
}
232+
case ThreadSubType.IS_IN_PRIORITY_INBOX: {
233+
return message_data.thread_is_in_priority_inbox;
234+
}
235+
case ThreadSubType.IS_IN_SPAM: {
236+
return message_data.thread_is_in_spam;
237+
}
238+
case ThreadSubType.IS_IN_TRASH: {
239+
return message_data.thread_is_in_trash;
240+
}
241+
case ThreadSubType.IS_STARRED: {
242+
return message_data.thread_is_starred;
243+
}
244+
case ThreadSubType.IS_UNREAD: {
245+
return message_data.thread_is_unread;
246+
}
247+
case ThreadSubType.FIRST_MESSAGE_SUBJECT: {
248+
return this.regexp.test(message_data.thread_first_message_subject);
249+
}
250+
case ThreadSubType.LABEL: {
251+
return this.matchAddress(...message_data.thread_labels);
252+
}
253+
}
254+
}
198255
}
199256
}
200257

@@ -212,7 +269,7 @@ export default class Condition {
212269
getConditionHeaders(): string[] {
213270
const headers = [];
214271
if (this.type === ConditionType.HEADER) {
215-
headers.push(this.subtype);
272+
headers.push(this.header);
216273
}
217274
this.sub_conditions?.forEach((sub_condition) => {
218275
headers.push(...sub_condition.getConditionHeaders());
@@ -291,27 +348,19 @@ export default class Condition {
291348
}
292349

293350
public static testConditionParsing(it: Function, expect: Function) {
294-
const base_message = {
295-
getFrom: () => '',
296-
getTo: () => '',
297-
getCc: () => '',
298-
getBcc: () => '',
299-
getReplyTo: () => '',
300-
getSubject: () => '',
301-
getPlainBody: () => '',
302-
getRawContent: () => '',
303-
getHeader: (_name: string) => '',
304-
} as GoogleAppsScript.Gmail.GmailMessage;
305351

306352
function test_cond(
307353
condition_str: string,
308354
message: Partial<GoogleAppsScript.Gmail.GmailMessage>,
309-
session_data: Partial<SessionData> = {}): boolean {
355+
thread: Partial<GoogleAppsScript.Gmail.GmailThread> = {},
356+
session_data: Partial<SessionData> = {},
357+
thread_labels: string[] = []): boolean {
310358
const condition = new Condition(condition_str);
359+
const mock_message = Mocks.getMockMessage(message, thread, thread_labels);
311360
const mock_session_data = Mocks.getMockSessionData(session_data);
312361
const message_data = new MessageData(
313362
mock_session_data,
314-
Object.assign({}, base_message, message));
363+
mock_message);
315364
return condition.match(message_data);
316365
}
317366

@@ -360,9 +409,163 @@ export default class Condition {
360409
getTo: () => 'abc+Def@bar.com',
361410
})).toBe(true)
362411
})
412+
it('Matches body using case-sensitivity', () => {
413+
expect(test_cond(`(body with aSdF)`,
414+
{
415+
getPlainBody: () => 'Text with aSdF in it',
416+
})).toBe(true)
417+
})
418+
it('Does not match body using case-sensitivity', () => {
419+
expect(test_cond(`(body asdf)`,
420+
{
421+
getPlainBody: () => 'Text with aSdF in it',
422+
})).toBe(false)
423+
})
424+
425+
function test_cond_labels(
426+
condition_str: string,
427+
thread_labels: string[]): boolean {
428+
return test_cond(condition_str, {}, {}, {}, thread_labels);
429+
}
430+
431+
it('Matches email that is in label, case-insensitive', () => {
432+
expect(test_cond_labels(`(thread label xyz)`,
433+
['ABC', 'XYZ', 'ABC/XYZ'])).toBe(true)
434+
})
435+
it('Does not match email that is in label with partial name', () => {
436+
expect(test_cond_labels(`(thread label XY)`,
437+
['ABC', 'XYZ', 'ABC/XYZ'])).toBe(false)
438+
})
439+
it('Does not match email that is in label without full name', () => {
440+
expect(test_cond_labels(`(thread label XYZ)`,
441+
['ABC/XYZ'])).toBe(false)
442+
})
443+
it('Matches email that is in label with full name', () => {
444+
expect(test_cond_labels(`(thread label ABC/XYZ)`,
445+
['ABC/XYZ'])).toBe(true)
446+
})
447+
448+
function test_cond_thread(
449+
condition_str: string,
450+
thread: Partial<GoogleAppsScript.Gmail.GmailThread>): boolean {
451+
return test_cond(condition_str, {}, thread);
452+
}
453+
454+
it('Throws exception if thread subtype is invalid', () => {
455+
expect(() => {test_cond_thread(`(thread is_made_up)`, {})}).toThrow()
456+
})
457+
it('Matches thread is_important if is', () => {
458+
expect(test_cond_thread(`(thread is_important)`,
459+
{
460+
isImportant: () => true,
461+
})).toBe(true)
462+
})
463+
it('Does not match thread is_important if it is not', () => {
464+
expect(test_cond_thread(`(thread is_important)`,
465+
{
466+
isImportant: () => false,
467+
})).toBe(false)
468+
})
469+
it('Matches thread is_in_inbox if is', () => {
470+
expect(test_cond_thread(`(thread is_in_inbox)`,
471+
{
472+
isInInbox: () => true,
473+
})).toBe(true)
474+
})
475+
it('Does not match thread is_in_inbox if it is not', () => {
476+
expect(test_cond_thread(`(thread is_in_inbox)`,
477+
{
478+
isInInbox: () => false,
479+
})).toBe(false)
480+
})
481+
it('Matches thread is_in_priority_inbox if is', () => {
482+
expect(test_cond_thread(`(thread is_in_priority_inbox)`,
483+
{
484+
isInPriorityInbox: () => true,
485+
})).toBe(true)
486+
})
487+
it('Does not match thread is_in_priority_inbox if it is not', () => {
488+
expect(test_cond_thread(`(thread is_in_priority_inbox)`,
489+
{
490+
isInPriorityInbox: () => false,
491+
})).toBe(false)
492+
})
493+
it('Matches thread is_in_spam if is', () => {
494+
expect(test_cond_thread(`(thread is_in_spam)`,
495+
{
496+
isInSpam: () => true,
497+
})).toBe(true)
498+
})
499+
it('Does not match thread is_in_spam if it is not', () => {
500+
expect(test_cond_thread(`(thread is_in_spam)`,
501+
{
502+
isInSpam: () => false,
503+
})).toBe(false)
504+
})
505+
it('Matches thread is_in_trash if it is', () => {
506+
expect(test_cond_thread(`(thread is_in_trash)`,
507+
{
508+
isInTrash: () => true,
509+
})).toBe(true)
510+
})
511+
it('Does not match thread is_in_trash if it is not', () => {
512+
expect(test_cond_thread(`(thread is_in_trash)`,
513+
{
514+
isInTrash: () => false,
515+
})).toBe(false)
516+
})
517+
it('Matches thread is_starred if it is', () => {
518+
expect(test_cond_thread(`(thread is_starred)`,
519+
{
520+
hasStarredMessages: () => true,
521+
})).toBe(true)
522+
})
523+
it('Does not match thread is_starred if it is not', () => {
524+
expect(test_cond_thread(`(thread is_starred)`,
525+
{
526+
hasStarredMessages: () => false,
527+
})).toBe(false)
528+
})
529+
it('Matches thread is_unread if it is', () => {
530+
expect(test_cond_thread(`(thread is_unread)`,
531+
{
532+
isUnread: () => true,
533+
})).toBe(true)
534+
})
535+
it('Does not match thread is_unread if it is not', () => {
536+
expect(test_cond_thread(`(thread is_unread)`,
537+
{
538+
isUnread: () => false,
539+
})).toBe(false)
540+
})
541+
it('Matches thread first_message_subject with case-sensitivity', () => {
542+
expect(test_cond_thread(`(thread first_message_subject this is IN subject)`,
543+
{
544+
getFirstMessageSubject: () => 'subject this is IN subjects',
545+
})).toBe(true)
546+
})
547+
it('Does not match thread first_message_subject with case-sensitivity', () => {
548+
expect(test_cond_thread(`(thread first_message_subject this is IN subject)`,
549+
{
550+
getFirstMessageSubject: () => 'subject this is in subjects',
551+
})).toBe(false)
552+
})
553+
it('Matches thread first_message_subject with regex', () => {
554+
expect(test_cond_thread(`(thread first_message_subject /teST Regex/i)`,
555+
{
556+
getFirstMessageSubject: () => 'RE: test regex subjectline',
557+
})).toBe(true)
558+
})
559+
560+
function test_cond_headers(
561+
condition_str: string,
562+
message: Partial<GoogleAppsScript.Gmail.GmailMessage>,
563+
session_data: Partial<SessionData>): boolean {
564+
return test_cond(condition_str, message, {}, session_data);
565+
}
363566

364567
it('Matches custom header with value', () => {
365-
expect(test_cond(`(header Sender abc@def.com)`,
568+
expect(test_cond_headers(`(header Sender abc@def.com)`,
366569
{
367570
getHeader: (name: string) => {
368571
if (name === 'Sender') {
@@ -376,7 +579,7 @@ export default class Condition {
376579
})).toBe(true)
377580
})
378581
it('Matches nested custom header with value', () => {
379-
expect(test_cond(`(and
582+
expect(test_cond_headers(`(and
380583
(from abc@gmail.com)
381584
(and
382585
(header X-List mylist.gmail.com)
@@ -398,7 +601,7 @@ export default class Condition {
398601
})).toBe(true)
399602
})
400603
it('Does not match custom header with incorrect data', () => {
401-
expect(test_cond(`(header MyHeader abc)`,
604+
expect(test_cond_headers(`(header MyHeader abc)`,
402605
{
403606
getHeader: (name: string) => {
404607
if (name === 'MyHeader') {

ThreadData.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,15 @@ export class MessageData {
6969
public readonly subject: string;
7070
public readonly body: string;
7171
public readonly headers: Map<string, string>;
72+
public readonly thread_labels: string[];
73+
public readonly thread_is_important: boolean;
74+
public readonly thread_is_in_inbox: boolean;
75+
public readonly thread_is_in_priority_inbox: boolean;
76+
public readonly thread_is_in_spam: boolean;
77+
public readonly thread_is_in_trash: boolean;
78+
public readonly thread_is_starred: boolean;
79+
public readonly thread_is_unread: boolean;
80+
public readonly thread_first_message_subject: string;
7281

7382
constructor(session_data: SessionData, message: GoogleAppsScript.Gmail.GmailMessage) {
7483
this.from = message.getFrom();
@@ -84,6 +93,19 @@ export class MessageData {
8493
session_data.requested_headers.forEach(header => {
8594
this.headers.set(header, message.getHeader(header));
8695
});
96+
this.thread_labels = [];
97+
const thread = message.getThread();
98+
thread.getLabels().forEach(label => {
99+
this.thread_labels.push(label.getName());
100+
});
101+
this.thread_is_important = thread.isImportant();
102+
this.thread_is_in_inbox = thread.isInInbox();
103+
this.thread_is_in_priority_inbox = thread.isInPriorityInbox();
104+
this.thread_is_in_spam = thread.isInSpam();
105+
this.thread_is_in_trash = thread.isInTrash();
106+
this.thread_is_starred = thread.hasStarredMessages();
107+
this.thread_is_unread = thread.isUnread();
108+
this.thread_first_message_subject = thread.getFirstMessageSubject();
87109
// Potentially could be HTML, Plain, or RAW. But doesn't seem very useful other than Plain.
88110
let body = message.getPlainBody();
89111
// Truncate and log long messages.

0 commit comments

Comments
 (0)