Skip to content

Commit 3e6fa8d

Browse files
author
Matt Diehl
committed
Match on arbitrary header and value.
Based on Issue #36, allows matching on any header and value. ``` (header X-Custom-Header /value/) ``` As we use `GmailMessage.getHeader` function, the header name used in the condition must be case-sensitive. Overall flow: - Rules are parsed, and a list of condition-requested-headers is created. - ThreadData reads in the message data, and all the headers that were requested in the rules (we have to pass SessionData to ThreadData so it knows which headers to search). - Condition.match checks what header to match with, and compares the header's value with the rule's value. In order to get the SessionData.mock.ts to work, SessionData.labels had to be made public. This seems to be a limitation we will continue to hit in testing if we want to test on Sheet Apps, since we cannot use other better libraries.
1 parent 4342985 commit 3e6fa8d

5 files changed

Lines changed: 147 additions & 8 deletions

File tree

Condition.ts

Lines changed: 92 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,14 @@
1515
*/
1616

1717
import {MessageData} from './ThreadData';
18+
import {SessionData} from './SessionData';
19+
import Mocks from './Mocks';
1820
import Utils from './utils';
1921

2022
const RE_FLAG_PATTERN = /^\/(.*)\/([gimuys]*)$/;
2123

2224
enum ConditionType {
23-
AND, OR, NOT, SUBJECT, FROM, TO, CC, BCC, LIST, SENDER, RECEIVER, BODY,
25+
AND, OR, NOT, SUBJECT, FROM, TO, CC, BCC, LIST, SENDER, RECEIVER, BODY, HEADER,
2426
}
2527

2628
/**
@@ -65,7 +67,7 @@ export default class Condition {
6567
return pattern.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
6668
}
6769

68-
private static parseRegExp(pattern: string, condition_str: string, matching_address: boolean): RegExp {
70+
public static parseRegExp(pattern: string, condition_str: string, matching_address: boolean): RegExp {
6971
Utils.assert(pattern.length > 0, `Condition ${condition_str} should have value but not found`);
7072
const match = pattern.match(RE_FLAG_PATTERN);
7173
if (match !== null) {
@@ -85,6 +87,7 @@ export default class Condition {
8587
}
8688

8789
private readonly type: ConditionType;
90+
private readonly subtype: string;
8891
private readonly regexp: RegExp;
8992
private readonly sub_conditions: Condition[];
9093

@@ -94,8 +97,9 @@ export default class Condition {
9497
`Condition ${condition_str} should be surrounded by ().`);
9598
const first_space = condition_str.indexOf(" ");
9699
const type_str = condition_str.substring(1, first_space).trim().toUpperCase();
97-
const rest_str = condition_str.substring(first_space + 1, condition_str.length - 1).trim();
100+
let rest_str = condition_str.substring(first_space + 1, condition_str.length - 1).trim();
98101
this.type = ConditionType[type_str as keyof typeof ConditionType];
102+
this.subtype = "";
99103
switch (this.type) {
100104
case ConditionType.AND:
101105
case ConditionType.OR: {
@@ -119,6 +123,13 @@ export default class Condition {
119123
this.regexp = Condition.parseRegExp(rest_str, condition_str, true);
120124
break;
121125
}
126+
case ConditionType.HEADER: {
127+
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);
131+
break;
132+
}
122133
case ConditionType.SUBJECT:
123134
case ConditionType.BODY: {
124135
this.regexp = Condition.parseRegExp(rest_str, condition_str, false);
@@ -177,6 +188,13 @@ export default class Condition {
177188
case ConditionType.BODY: {
178189
return this.regexp.test(message_data.body);
179190
}
191+
case ConditionType.HEADER: {
192+
const headerData = message_data.headers.get(this.subtype);
193+
if (headerData !== undefined) {
194+
return this.regexp.test(headerData);
195+
}
196+
return false;
197+
}
180198
}
181199
}
182200

@@ -191,6 +209,17 @@ export default class Condition {
191209
return `(${type_str} ${regexp_str} ${sub_str})`;
192210
}
193211

212+
getConditionHeaders(): string[] {
213+
const headers = [];
214+
if (this.type === ConditionType.HEADER) {
215+
headers.push(this.subtype);
216+
}
217+
this.sub_conditions?.forEach((sub_condition) => {
218+
headers.push(...sub_condition.getConditionHeaders());
219+
});
220+
return headers;
221+
}
222+
194223
public static testRegex(it: Function, expect: Function) {
195224

196225
function test_regexp(condition_str: string, target_str: string, is_address: boolean) {
@@ -271,11 +300,18 @@ export default class Condition {
271300
getSubject: () => '',
272301
getPlainBody: () => '',
273302
getRawContent: () => '',
303+
getHeader: (_name: string) => '',
274304
} as GoogleAppsScript.Gmail.GmailMessage;
275305

276-
function test_cond(condition_str: string, message: Partial<GoogleAppsScript.Gmail.GmailMessage>): boolean {
306+
function test_cond(
307+
condition_str: string,
308+
message: Partial<GoogleAppsScript.Gmail.GmailMessage>,
309+
session_data: Partial<SessionData> = {}): boolean {
277310
const condition = new Condition(condition_str);
278-
const message_data = new MessageData(Object.assign({}, base_message, message));
311+
const mock_session_data = Mocks.getMockSessionData(session_data);
312+
const message_data = new MessageData(
313+
mock_session_data,
314+
Object.assign({}, base_message, message));
279315
return condition.match(message_data);
280316
}
281317

@@ -324,5 +360,56 @@ export default class Condition {
324360
getTo: () => 'abc+Def@bar.com',
325361
})).toBe(true)
326362
})
363+
364+
it('Matches custom header with value', () => {
365+
expect(test_cond(`(header Sender abc@def.com)`,
366+
{
367+
getHeader: (name: string) => {
368+
if (name === 'Sender') {
369+
return 'abc@def.com';
370+
}
371+
return '';
372+
},
373+
},
374+
{
375+
requested_headers: ['Sender', 'List-Post'],
376+
})).toBe(true)
377+
})
378+
it('Matches nested custom header with value', () => {
379+
expect(test_cond(`(and
380+
(from abc@gmail.com)
381+
(and
382+
(header X-List mylist.gmail.com)
383+
(header Precedence /list/i)))`,
384+
{
385+
getFrom: () => 'DDD EEE <abc@gmail.com>',
386+
getHeader: (name: string) => {
387+
if (name === 'X-List') {
388+
return 'mylist.gmail.com';
389+
}
390+
if (name === 'Precedence') {
391+
return 'bills list';
392+
}
393+
return '';
394+
},
395+
},
396+
{
397+
requested_headers: ['X-List', 'Precedence'],
398+
})).toBe(true)
399+
})
400+
it('Does not match custom header with incorrect data', () => {
401+
expect(test_cond(`(header MyHeader abc)`,
402+
{
403+
getHeader: (name: string) => {
404+
if (name === 'MyHeader') {
405+
return 'xyz';
406+
}
407+
return '';
408+
},
409+
},
410+
{
411+
requested_headers: ['MyHeader'],
412+
})).toBe(false)
413+
})
327414
}
328415
}

Mocks.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export default class Mocks {
2424
config: Mocks.getMockConfig(),
2525
labels: {},
2626
rules: [],
27+
requested_headers: [],
2728
processing_start_time: new Date(12345),
2829
oldest_to_process: new Date(23456),
2930
getOrCreateLabel: () => ({} as GoogleAppsScript.Gmail.GmailLabel),

Rule.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ export class Rule {
3535
return this.condition.toString();
3636
}
3737

38+
getConditionHeaders(): string[] {
39+
return this.condition.getConditionHeaders();
40+
}
41+
3842
private static parseBooleanValue(str: string): boolean {
3943
if (str.length === 0) {
4044
return false;
@@ -86,6 +90,15 @@ export class Rule {
8690
return result;
8791
}
8892

93+
public static getConditionHeaders(rules: Rule[]): string[] {
94+
const headers: Set<string> = new Set<string>();
95+
rules.forEach((rule) => {
96+
const rule_headers = rule.getConditionHeaders();
97+
rule_headers.forEach(item => headers.add(item))
98+
});
99+
return Array.from(headers.values())
100+
}
101+
89102
public static parseRules(values: string[][]): Rule[] {
90103
const row_num = values.length;
91104
const column_num = values[0].length;
@@ -206,10 +219,12 @@ export class Rule {
206219
}]);
207220

208221
const rules = Rule.parseRules(sheet);
222+
const condition_headers = Rule.getConditionHeaders(rules);
209223

210224
expect(rules.length).toBe(1);
211225
expect(rules[0].stage).toBe(5);
212226
expect(rules[0].thread_action.label_names.size).toBe(2);
227+
expect(condition_headers).toEqual([]);
213228
})
214229

215230
it('Loaded Rules are sorted by stage', () => {
@@ -239,5 +254,34 @@ export class Rule {
239254
expect(rules[2].stage).toBe(15);
240255
})
241256

257+
it('Loads rules with Headers', () => {
258+
const sheet = Mocks.getMockTestSheet([
259+
{
260+
conditions: '(and (or (header Test1 /abc/i)' +
261+
' (header h2 xyz@gmail.com))' +
262+
' (and' +
263+
' (header X-List abcde)' +
264+
' (header h3 /abcde/)))',
265+
add_labels: 'def, uvw',
266+
stage: "10",
267+
},
268+
{
269+
conditions: '(header h5 /asdf/)',
270+
add_labels: 'abc',
271+
stage: "15",
272+
}
273+
]);
274+
275+
const rules = Rule.parseRules(sheet);
276+
const condition_headers = Rule.getConditionHeaders(rules);
277+
278+
expect(rules.length).toBe(2);
279+
expect(rules[0].stage).toBe(10);
280+
expect(rules[0].thread_action.label_names).toEqual(new Set(['def', 'uvw']));
281+
expect(rules[1].stage).toBe(15);
282+
expect(rules[1].thread_action.label_names).toEqual(new Set(['abc']));
283+
expect(condition_headers).toEqual(
284+
['Test1', 'h2', 'X-List', 'h3', 'h5']);
285+
})
242286
}
243287
}

SessionData.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export class SessionData {
3232
public readonly config: Config;
3333
public readonly labels: { [key: string]: GoogleAppsScript.Gmail.GmailLabel };
3434
public readonly rules: Rule[];
35+
public readonly requested_headers: string[];
3536

3637
public readonly processing_start_time: Date;
3738
public readonly oldest_to_process: Date;
@@ -41,14 +42,15 @@ export class SessionData {
4142
this.config = Utils.withTimer("getConfigs", () => Config.getConfig());
4243
this.labels = Utils.withTimer("getLabels", () => SessionData.getLabelMap());
4344
this.rules = Utils.withTimer("getRules", () => Rule.getRules());
45+
this.requested_headers = Utils.withTimer("getHeaders", () => Rule.getConditionHeaders(this.rules));
4446

4547
this.processing_start_time = new Date();
4648
// Check back two processing intervals to make sure we checked all messages in the thread
4749
this.oldest_to_process = new Date(
4850
this.processing_start_time.getTime() - 2 * this.config.processing_frequency_in_minutes * 60 * 1000);
4951
}
5052

51-
getOrCreateLabel(name: string) {
53+
getOrCreateLabel(name: string): GoogleAppsScript.Gmail.GmailLabel {
5254
name = name.trim();
5355
Utils.assert(name.length > 0, "Can't get empty label!");
5456

ThreadData.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,9 @@ export class MessageData {
6868
public readonly receivers: string[];
6969
public readonly subject: string;
7070
public readonly body: string;
71+
public readonly headers: Map<string, string>;
7172

72-
constructor(message: GoogleAppsScript.Gmail.GmailMessage) {
73+
constructor(session_data: SessionData, message: GoogleAppsScript.Gmail.GmailMessage) {
7374
this.from = message.getFrom();
7475
this.to = MessageData.parseAddresses(message.getTo());
7576
this.cc = MessageData.parseAddresses(message.getCc());
@@ -79,6 +80,10 @@ export class MessageData {
7980
this.sender = ([] as string[]).concat(this.from, this.reply_to);
8081
this.receivers = ([] as string[]).concat(this.to, this.cc, this.bcc, this.list);
8182
this.subject = message.getSubject();
83+
this.headers = new Map<string, string>();
84+
session_data.requested_headers.forEach(header => {
85+
this.headers.set(header, message.getHeader(header));
86+
});
8287
// Potentially could be HTML, Plain, or RAW. But doesn't seem very useful other than Plain.
8388
let body = message.getPlainBody();
8489
// Truncate and log long messages.
@@ -111,7 +116,7 @@ export class ThreadData {
111116
if (newMessages.length === 0) {
112117
newMessages = [messages[messages.length - 1]];
113118
}
114-
this.message_data_list = newMessages.map(message => new MessageData(message));
119+
this.message_data_list = newMessages.map(message => new MessageData(session_data, message));
115120

116121
// Log if any dropped.
117122
const numDropped = messages.length - newMessages.length;

0 commit comments

Comments
 (0)