Skip to content

Commit 4528a0a

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 1e62d4a commit 4528a0a

7 files changed

Lines changed: 225 additions & 16 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
}

Config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ export class Config implements Readonly<MutableConfig> {
5757

5858
const values = Utils.withTimer("GetConfigValues", () => {
5959
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('configs');
60+
if (sheet === null) {
61+
throw new Error("Sheet 'configs' does not exist in this Spreadsheet.");
62+
}
6063
const num_rows = sheet.getLastRow();
6164
return sheet.getRange(1, 1, num_rows, 2).getDisplayValues().map(row => row.map(cell => cell.trim()));
6265
});

Mocks.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import {SessionData} from "SessionData";
2+
import {Config} from "Config";
3+
4+
5+
export default class Mocks {
6+
7+
private static base_config: Config = {
8+
auto_labeling_parent_label: "",
9+
go_link: "",
10+
hour_of_day_to_run_sanity_checking: 0,
11+
max_threads: 50,
12+
processed_label: "myProcessed",
13+
processing_failed_label: "zFailed",
14+
processing_frequency_in_minutes: 5,
15+
unprocessed_label: "myUnprocessed",
16+
};
17+
18+
public static getMockConfig = (overrides: Partial<Config> = {}) => (
19+
Object.assign({}, Mocks.base_config, overrides)
20+
);
21+
22+
private static base_session_data: SessionData = {
23+
user_email: "abc@gmail.com",
24+
config: Mocks.getMockConfig(),
25+
labels: {},
26+
rules: [],
27+
requested_headers: [],
28+
processing_start_time: new Date(12345),
29+
oldest_to_process: new Date(23456),
30+
getOrCreateLabel: () => ({} as GoogleAppsScript.Gmail.GmailLabel),
31+
};
32+
33+
public static getMockSessionData = (overrides: Partial<SessionData> = {}) => (
34+
Object.assign({}, Mocks.base_session_data, overrides)
35+
);
36+
}

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ Click menu "Gmail Automata" -> "Stop auto processing" to remove auto triggering.
127127
3. Update the script id in ".clasp.json" file. To find the script id:
128128
1. Setup the script following the section [Setup](#Setup) above if you
129129
haven't do it.
130-
2. In the spreadsheet, click menu "Extensions" -> "App Script".
130+
2. In the spreadsheet, click menu "Extensions" -> "Apps Script".
131131
3. In the script editor, click menu "Project Settings" > "IDs" > "ScriptID".
132132
4. Login CLASP: `yarn claspLogin` and authorize the app in the browser.
133133
5. Deploy current version: `yarn deploy`.

Rule.ts

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

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

92+
public static getConditionHeaders(rules: Rule[]): string[] {
93+
const headers: Set<string> = new Set<string>();
94+
rules.forEach((rule) => {
95+
const rule_headers = rule.getConditionHeaders();
96+
rule_headers.forEach(item => headers.add(item))
97+
});
98+
return Array.from(headers.values())
99+
}
100+
88101
private static parseRules(values: string[][]): Rule[] {
89102
const row_num = values.length;
90103
const column_num = values[0].length;
@@ -150,6 +163,9 @@ export class Rule {
150163
public static getRules(): Rule[] {
151164
const values: string[][] = Utils.withTimer("GetRuleValues", () => {
152165
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('rules');
166+
if(sheet === null) {
167+
throw "Active sheet 'rules' not found";
168+
}
153169
const column_num = sheet.getLastColumn();
154170
const row_num = sheet.getLastRow();
155171
return sheet.getRange(1, 1, row_num, column_num)
@@ -216,21 +232,81 @@ export class Rule {
216232
})
217233

218234
it('Loads Simple Rule', () => {
219-
const row: string[] = new_row({
220-
conditions: '(body /to: me/i)',
221-
add_labels: 'abc, xyz',
222-
stage: "5",
223-
});
224235
const sheet: string[][] = [
225236
headers,
226-
row,
237+
new_row({
238+
conditions: '(body /to: me/i)',
239+
add_labels: 'abc, xyz',
240+
stage: "5",
241+
}),
227242
]
228243

229244
const rules = Rule.parseRules(sheet);
245+
const condition_headers = Rule.getConditionHeaders(rules);
230246

231247
expect(rules.length).toBe(1);
232248
expect(rules[0].stage).toBe(5);
233249
expect(rules[0].thread_action.label_names.size).toBe(2);
250+
expect(condition_headers).toEqual([]);
251+
})
252+
253+
it('Loaded Rules are sorted by stage', () => {
254+
const sheet: string[][] = [
255+
headers,
256+
new_row({
257+
conditions: '(body /to: me/i)',
258+
add_labels: 'abc, xyz',
259+
stage: "5",
260+
}),
261+
new_row({
262+
conditions: '(body /to: me/i)',
263+
add_labels: 'abc, xyz',
264+
stage: "15",
265+
}),
266+
new_row({
267+
conditions: '(body /to: me/i)',
268+
add_labels: 'abc, xyz',
269+
stage: "1",
270+
}),
271+
]
272+
273+
const rules = Rule.parseRules(sheet);
274+
275+
expect(rules.length).toBe(3);
276+
expect(rules[0].stage).toBe(1);
277+
expect(rules[1].stage).toBe(5);
278+
expect(rules[2].stage).toBe(15);
279+
})
280+
281+
it('Loads rules with Headers', () => {
282+
const sheet: string[][] = [
283+
headers,
284+
new_row({
285+
conditions: '(and (or (header Test1 /abc/i)' +
286+
' (header h2 xyz@gmail.com))' +
287+
' (and' +
288+
' (header X-List abcde)' +
289+
' (header h3 /abcde/)))',
290+
add_labels: 'def, uvw',
291+
stage: "10",
292+
}),
293+
new_row({
294+
conditions: '(header h5 /asdf/)',
295+
add_labels: 'abc',
296+
stage: "15",
297+
}),
298+
]
299+
300+
const rules = Rule.parseRules(sheet);
301+
const condition_headers = Rule.getConditionHeaders(rules);
302+
303+
expect(rules.length).toBe(2);
304+
expect(rules[0].stage).toBe(10);
305+
expect(rules[0].thread_action.label_names).toEqual(new Set(['def', 'uvw']));
306+
expect(rules[1].stage).toBe(15);
307+
expect(rules[1].thread_action.label_names).toEqual(new Set(['abc']));
308+
expect(condition_headers).toEqual(
309+
['Test1', 'h2', 'X-List', 'h3', 'h5']);
234310
})
235311
}
236312
}

SessionData.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,9 @@ export class SessionData {
3030

3131
public readonly user_email: string;
3232
public readonly config: Config;
33-
private readonly labels: { [key: string]: GoogleAppsScript.Gmail.GmailLabel };
33+
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

0 commit comments

Comments
 (0)