@kubun/connector-google-mail 0.17.3 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/actions.d.ts +1 -1
- package/lib/actions.js +8 -5
- package/lib/api.d.ts +11 -0
- package/lib/api.js +94 -4
- package/lib/model.d.ts +6 -49
- package/lib/model.js +26 -59
- package/lib/provider.js +36 -12
- package/package.json +11 -9
package/lib/actions.d.ts
CHANGED
package/lib/actions.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
|
+
import { carryField, refuseCutLists } from '@kubun/connector';
|
|
1
2
|
import { createDraft, createLabel, importMessageAttachment, modifyLabels, sendMessage, updateLabel } from './api.js';
|
|
2
3
|
import { GoogleMailError } from './errors.js';
|
|
3
4
|
// Gmail takes a label color only as the textColor/backgroundColor pair, so a
|
|
4
5
|
// write that changes one of them carries the other from the target document.
|
|
5
6
|
// With no companion to carry (absent or empty), the color cannot be written at all: the write is
|
|
6
|
-
// rejected before any request, rather than sent without the change.
|
|
7
|
-
|
|
7
|
+
// rejected before any request, rather than sent without the change. A
|
|
8
|
+
// companion trimmed at sync is refused too (`carryField`), never sent.
|
|
9
|
+
function withColorPair(params, existing, context) {
|
|
8
10
|
const changesOne = 'textColor' in params !== 'backgroundColor' in params;
|
|
9
11
|
if (!changesOne) return params;
|
|
10
12
|
const missing = 'textColor' in params ? 'backgroundColor' : 'textColor';
|
|
11
|
-
const companion = existing
|
|
13
|
+
const companion = carryField(existing, missing, context);
|
|
12
14
|
if (typeof companion !== 'string' || companion === '') {
|
|
13
15
|
throw new GoogleMailError(`Google Mail: a label color needs both textColor and backgroundColor, and the label has no ${missing} to keep`, {
|
|
14
16
|
kind: 'rejected'
|
|
@@ -58,12 +60,13 @@ export function createGoogleMailActionHandler(config) {
|
|
|
58
60
|
sourceFetchHLC: mintActionFetchHLC()
|
|
59
61
|
};
|
|
60
62
|
},
|
|
61
|
-
async update (model, sourceID, params, existing) {
|
|
63
|
+
async update (model, sourceID, params, existing, context) {
|
|
64
|
+
refuseCutLists(params, context);
|
|
62
65
|
if (model === 'GoogleMailLabel') {
|
|
63
66
|
const record = await updateLabel({
|
|
64
67
|
accessToken: config.credential.accessToken,
|
|
65
68
|
labelID: sourceID,
|
|
66
|
-
params: withColorPair(params, existing),
|
|
69
|
+
params: withColorPair(params, existing, context),
|
|
67
70
|
fetch: config.fetch
|
|
68
71
|
});
|
|
69
72
|
return {
|
package/lib/api.d.ts
CHANGED
|
@@ -103,6 +103,17 @@ export declare function getHeader(headers: Array<{
|
|
|
103
103
|
}> | undefined, name: string): string | undefined;
|
|
104
104
|
export declare function extractEmail(headerValue: string): string;
|
|
105
105
|
export declare function extractEmails(headerValue: string | undefined): Array<string>;
|
|
106
|
+
export type MailParty = {
|
|
107
|
+
name?: string;
|
|
108
|
+
email?: string;
|
|
109
|
+
};
|
|
110
|
+
/**
|
|
111
|
+
* Parses one mailbox out of a header value: `"Alice A" <alice@example.com>`,
|
|
112
|
+
* `Alice A <alice@example.com>`, or a bare `alice@example.com`. A value that
|
|
113
|
+
* names no address at all -- malformed or empty -- returns `{}` rather than
|
|
114
|
+
* a name with no email, or an email this parser only guessed at.
|
|
115
|
+
*/
|
|
116
|
+
export declare function parseMailParty(value: string): MailParty;
|
|
106
117
|
export declare function mapMessageToEntity(message: GmailMessageResource): EntityRecord;
|
|
107
118
|
export declare function getMessage(config: {
|
|
108
119
|
accessToken: string;
|
package/lib/api.js
CHANGED
|
@@ -78,6 +78,93 @@ export function extractEmails(headerValue) {
|
|
|
78
78
|
if (headerValue == null || headerValue.trim() === '') return [];
|
|
79
79
|
return headerValue.split(',').map((part)=>extractEmail(part));
|
|
80
80
|
}
|
|
81
|
+
// A mailbox is either `Display Name <addr@example.com>` (the display name
|
|
82
|
+
// optionally quoted) or a bare `addr@example.com`. This is a liberal but
|
|
83
|
+
// bounded check for "looks like an email address" -- no whitespace, angle
|
|
84
|
+
// bracket or quote characters around a single '@', with at least one '.' in
|
|
85
|
+
// the domain -- never a full RFC 5322 grammar. Gmail headers are already
|
|
86
|
+
// provider-validated, so this only guards against a header this parser
|
|
87
|
+
// cannot make sense of; it never invents an address that is not there.
|
|
88
|
+
const MAILBOX_WITH_NAME = /^([^<>]*)<([^<>]*)>\s*$/;
|
|
89
|
+
const PLAUSIBLE_EMAIL = /^[^\s<>"@]+@[^\s<>"@]+\.[^\s<>"@]+$/;
|
|
90
|
+
function stripQuotes(value) {
|
|
91
|
+
const trimmed = value.trim();
|
|
92
|
+
const match = /^"(.*)"$/.exec(trimmed);
|
|
93
|
+
return match?.[1]?.replace(/\\(["\\])/g, '$1') ?? trimmed;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Parses one mailbox out of a header value: `"Alice A" <alice@example.com>`,
|
|
97
|
+
* `Alice A <alice@example.com>`, or a bare `alice@example.com`. A value that
|
|
98
|
+
* names no address at all -- malformed or empty -- returns `{}` rather than
|
|
99
|
+
* a name with no email, or an email this parser only guessed at.
|
|
100
|
+
*/ export function parseMailParty(value) {
|
|
101
|
+
const trimmed = value.trim();
|
|
102
|
+
if (trimmed === '') return {};
|
|
103
|
+
const withName = MAILBOX_WITH_NAME.exec(trimmed);
|
|
104
|
+
if (withName != null) {
|
|
105
|
+
const email = (withName[2] ?? '').trim();
|
|
106
|
+
if (!PLAUSIBLE_EMAIL.test(email)) return {};
|
|
107
|
+
const name = stripQuotes(withName[1] ?? '');
|
|
108
|
+
return name === '' ? {
|
|
109
|
+
email
|
|
110
|
+
} : {
|
|
111
|
+
name,
|
|
112
|
+
email
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
return PLAUSIBLE_EMAIL.test(trimmed) ? {
|
|
116
|
+
email: trimmed
|
|
117
|
+
} : {};
|
|
118
|
+
}
|
|
119
|
+
// Split only at mailbox boundaries outside quoted names and angle addresses.
|
|
120
|
+
// A group label is not a mailbox; its comma-separated members are.
|
|
121
|
+
function splitMailboxes(headerValue) {
|
|
122
|
+
const parts = [];
|
|
123
|
+
let current = '';
|
|
124
|
+
let inQuotes = false;
|
|
125
|
+
let inAngle = false;
|
|
126
|
+
let inGroup = false;
|
|
127
|
+
const push = ()=>{
|
|
128
|
+
if (current.trim() !== '') parts.push(current.trim());
|
|
129
|
+
current = '';
|
|
130
|
+
};
|
|
131
|
+
for(let i = 0; i < headerValue.length; i++){
|
|
132
|
+
const char = headerValue[i];
|
|
133
|
+
if (char === '\\' && inQuotes && i + 1 < headerValue.length) {
|
|
134
|
+
current += char + headerValue[++i];
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (char === '"' && !inAngle) inQuotes = !inQuotes;
|
|
138
|
+
if (!inQuotes && char === '<') inAngle = true;
|
|
139
|
+
if (!inQuotes && char === '>') inAngle = false;
|
|
140
|
+
if (!inQuotes && !inAngle) {
|
|
141
|
+
if (char === ':' && !inGroup && /^[^@<>:;]+$/.test(current.trim())) {
|
|
142
|
+
current = '';
|
|
143
|
+
inGroup = true;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (char === ',' || char === ';' && inGroup) {
|
|
147
|
+
push();
|
|
148
|
+
if (char === ';') inGroup = false;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
current += char;
|
|
153
|
+
if (char === '>' && !inQuotes) {
|
|
154
|
+
const next = headerValue.slice(i + 1).trimStart();
|
|
155
|
+
if (next.startsWith('<')) push();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
push();
|
|
159
|
+
return parts;
|
|
160
|
+
}
|
|
161
|
+
function parseMailPartyList(headerValue) {
|
|
162
|
+
if (headerValue == null || headerValue.trim() === '') return [];
|
|
163
|
+
return splitMailboxes(headerValue).map(parseMailParty).filter((party)=>party.name != null || party.email != null);
|
|
164
|
+
}
|
|
165
|
+
function nonEmptyPartyList(parties) {
|
|
166
|
+
return parties.length === 0 ? undefined : parties;
|
|
167
|
+
}
|
|
81
168
|
function findPartByMime(part, mime) {
|
|
82
169
|
if (part.mimeType === mime && part.body?.data != null) return part;
|
|
83
170
|
for (const child of part.parts ?? []){
|
|
@@ -117,16 +204,19 @@ export function mapMessageToEntity(message) {
|
|
|
117
204
|
const headers = message.payload?.headers;
|
|
118
205
|
const labels = message.labelIds ?? [];
|
|
119
206
|
const sentAtMs = message.internalDate != null ? Number.parseInt(message.internalDate, 10) : Number.NaN;
|
|
207
|
+
const from = parseMailParty(getHeader(headers, 'From') ?? '');
|
|
120
208
|
return {
|
|
121
209
|
sourceService: 'google-mail',
|
|
122
210
|
sourceID: message.id,
|
|
123
211
|
sourceURL: `https://mail.google.com/mail/u/0/#inbox/${message.id}`,
|
|
124
212
|
body: extractBody(message.payload),
|
|
125
|
-
|
|
213
|
+
// A message with no From header, or one this parser cannot make sense
|
|
214
|
+
// of, carries no `from` rather than an empty Party.
|
|
215
|
+
from: from.name != null || from.email != null ? from : undefined,
|
|
126
216
|
subject: getHeader(headers, 'Subject'),
|
|
127
217
|
snippet: message.snippet,
|
|
128
|
-
to:
|
|
129
|
-
cc:
|
|
218
|
+
to: nonEmptyPartyList(parseMailPartyList(getHeader(headers, 'To'))),
|
|
219
|
+
cc: nonEmptyPartyList(parseMailPartyList(getHeader(headers, 'Cc'))),
|
|
130
220
|
labels,
|
|
131
221
|
unread: labels.includes('UNREAD'),
|
|
132
222
|
sentAt: Number.isNaN(sentAtMs) ? undefined : new Date(sentAtMs).toISOString(),
|
|
@@ -262,7 +352,7 @@ function requireString(params, key) {
|
|
|
262
352
|
}
|
|
263
353
|
// Gmail's write endpoints (messages.send, drafts.create, messages.modify) return only
|
|
264
354
|
// { id, threadId, labelIds } -- no payload/headers/body. Re-fetch the full message so the
|
|
265
|
-
// mapped entity carries real
|
|
355
|
+
// mapped entity carries real from/body/subject/etc. instead of empty placeholders.
|
|
266
356
|
// Everything here runs after the write went out, so any failure is marked sent: the
|
|
267
357
|
// write may have landed, whatever the read-back did.
|
|
268
358
|
async function readBackWrittenMessage(config) {
|
package/lib/model.d.ts
CHANGED
|
@@ -5,10 +5,6 @@ export declare const googleMailThreadDefinition: {
|
|
|
5
5
|
readonly schema: {
|
|
6
6
|
readonly type: "object";
|
|
7
7
|
readonly properties: {
|
|
8
|
-
readonly title: {
|
|
9
|
-
readonly type: "string";
|
|
10
|
-
readonly maxLength: 200;
|
|
11
|
-
};
|
|
12
8
|
readonly sourceService: {
|
|
13
9
|
readonly $ref: "#/definitions/SourceService";
|
|
14
10
|
};
|
|
@@ -47,7 +43,7 @@ export declare const googleMailThreadDefinition: {
|
|
|
47
43
|
};
|
|
48
44
|
readonly fieldsMeta: {};
|
|
49
45
|
readonly providerMergePolicy: {
|
|
50
|
-
readonly group: [
|
|
46
|
+
readonly group: [...string[], "sourceService", "sourceID", "sourceURL"];
|
|
51
47
|
};
|
|
52
48
|
};
|
|
53
49
|
export declare const googleMailMessageDefinition: {
|
|
@@ -57,43 +53,16 @@ export declare const googleMailMessageDefinition: {
|
|
|
57
53
|
readonly schema: {
|
|
58
54
|
readonly type: "object";
|
|
59
55
|
readonly properties: {
|
|
60
|
-
readonly body: {
|
|
61
|
-
readonly type: "string";
|
|
62
|
-
};
|
|
63
|
-
readonly sender: {
|
|
64
|
-
readonly type: "string";
|
|
65
|
-
readonly minLength: 1;
|
|
66
|
-
readonly maxLength: 320;
|
|
67
|
-
};
|
|
68
|
-
readonly subject: {
|
|
69
|
-
readonly type: "string";
|
|
70
|
-
};
|
|
71
56
|
readonly snippet: {
|
|
72
57
|
readonly type: "string";
|
|
73
58
|
};
|
|
74
|
-
readonly to: {
|
|
75
|
-
readonly type: "array";
|
|
76
|
-
readonly title: "To";
|
|
77
|
-
readonly items: {
|
|
78
|
-
readonly type: "string";
|
|
79
|
-
};
|
|
80
|
-
};
|
|
81
|
-
readonly cc: {
|
|
82
|
-
readonly type: "array";
|
|
83
|
-
readonly title: "Cc";
|
|
84
|
-
readonly items: {
|
|
85
|
-
readonly type: "string";
|
|
86
|
-
};
|
|
87
|
-
};
|
|
88
59
|
readonly labels: {
|
|
89
60
|
readonly type: "array";
|
|
90
61
|
readonly title: "Labels";
|
|
91
62
|
readonly items: {
|
|
92
63
|
readonly type: "string";
|
|
93
64
|
};
|
|
94
|
-
|
|
95
|
-
readonly sentAt: {
|
|
96
|
-
readonly $ref: "#/definitions/DateTime";
|
|
65
|
+
readonly maxItems: 100;
|
|
97
66
|
};
|
|
98
67
|
readonly unread: {
|
|
99
68
|
readonly type: "boolean";
|
|
@@ -104,13 +73,11 @@ export declare const googleMailMessageDefinition: {
|
|
|
104
73
|
readonly items: {
|
|
105
74
|
readonly $ref: "#/definitions/Attachment";
|
|
106
75
|
};
|
|
76
|
+
readonly maxItems: 100;
|
|
107
77
|
};
|
|
108
78
|
readonly threadID: {
|
|
109
79
|
readonly type: "string";
|
|
110
80
|
};
|
|
111
|
-
readonly thread: {
|
|
112
|
-
readonly $ref: "#/definitions/ThreadDocID";
|
|
113
|
-
};
|
|
114
81
|
readonly sourceService: {
|
|
115
82
|
readonly $ref: "#/definitions/SourceService";
|
|
116
83
|
};
|
|
@@ -124,14 +91,9 @@ export declare const googleMailMessageDefinition: {
|
|
|
124
91
|
readonly $ref: "#/definitions/DateTime";
|
|
125
92
|
};
|
|
126
93
|
};
|
|
127
|
-
readonly required: ["
|
|
94
|
+
readonly required: ["threadID", "thread", "sourceService", "sourceID", "lastSyncedAt"];
|
|
128
95
|
readonly additionalProperties: false;
|
|
129
96
|
readonly definitions: {
|
|
130
|
-
readonly ThreadDocID: {
|
|
131
|
-
readonly type: "string";
|
|
132
|
-
readonly title: "DocID";
|
|
133
|
-
readonly pattern: "^k[0-9a-z]{10,120}$";
|
|
134
|
-
};
|
|
135
97
|
readonly Attachment: {
|
|
136
98
|
readonly type: "object";
|
|
137
99
|
readonly title: "Attachment";
|
|
@@ -176,14 +138,9 @@ export declare const googleMailMessageDefinition: {
|
|
|
176
138
|
readonly DateTime: import("json-schema-typed").JSONSchema.String;
|
|
177
139
|
};
|
|
178
140
|
};
|
|
179
|
-
readonly fieldsMeta: {
|
|
180
|
-
readonly thread: {
|
|
181
|
-
readonly type: "document";
|
|
182
|
-
readonly model: "#0";
|
|
183
|
-
};
|
|
184
|
-
};
|
|
141
|
+
readonly fieldsMeta: {};
|
|
185
142
|
readonly providerMergePolicy: {
|
|
186
|
-
readonly group: [
|
|
143
|
+
readonly group: [...string[], "sourceService", "sourceID", "sourceURL"];
|
|
187
144
|
};
|
|
188
145
|
};
|
|
189
146
|
export declare const googleMailLabelDefinition: {
|
package/lib/model.js
CHANGED
|
@@ -1,21 +1,25 @@
|
|
|
1
1
|
import { connectorClusters, connectorModelIDs, externalEntityDefinition } from '@kubun/connector';
|
|
2
|
-
import {
|
|
2
|
+
import { SHORT_LIST_MAX_ITEMS } from '@kubun/models-core';
|
|
3
|
+
import { messagingClusters, messagingModelDefinitions, messagingModelIDs } from '@kubun/models-messaging';
|
|
3
4
|
import { GraphBuilder } from '@kubun/protocol';
|
|
4
|
-
|
|
5
|
+
const threadKindProviderFields = messagingModelDefinitions.find((definition)=>definition.name === 'ThreadKind')?.providerMergePolicy?.group;
|
|
6
|
+
const messageKindProviderFields = messagingModelDefinitions.find((definition)=>definition.name === 'MessageKind')?.providerMergePolicy?.group;
|
|
7
|
+
const emailKindProviderFields = messagingModelDefinitions.find((definition)=>definition.name === 'EmailKind')?.providerMergePolicy?.group;
|
|
8
|
+
if (threadKindProviderFields == null || messageKindProviderFields == null || emailKindProviderFields == null) {
|
|
9
|
+
throw new Error('Messaging provider fields are missing');
|
|
10
|
+
}
|
|
11
|
+
// The shared thread field (title) comes from ThreadKind; only what is
|
|
12
|
+
// Google's own is declared here.
|
|
5
13
|
export const googleMailThreadDefinition = {
|
|
6
14
|
name: 'GoogleMailThread',
|
|
7
15
|
behavior: 'default',
|
|
8
16
|
interfaces: [
|
|
9
17
|
connectorModelIDs.ExternalEntity,
|
|
10
|
-
|
|
18
|
+
messagingModelIDs.ThreadKind
|
|
11
19
|
],
|
|
12
20
|
schema: {
|
|
13
21
|
type: 'object',
|
|
14
22
|
properties: {
|
|
15
|
-
title: {
|
|
16
|
-
type: 'string',
|
|
17
|
-
maxLength: 200
|
|
18
|
-
},
|
|
19
23
|
sourceService: {
|
|
20
24
|
$ref: '#/definitions/SourceService'
|
|
21
25
|
},
|
|
@@ -44,60 +48,35 @@ export const googleMailThreadDefinition = {
|
|
|
44
48
|
// provider content, so it stays outside the group.
|
|
45
49
|
providerMergePolicy: {
|
|
46
50
|
group: [
|
|
47
|
-
|
|
51
|
+
...threadKindProviderFields,
|
|
48
52
|
'sourceService',
|
|
49
53
|
'sourceID',
|
|
50
54
|
'sourceURL'
|
|
51
55
|
]
|
|
52
56
|
}
|
|
53
57
|
};
|
|
58
|
+
// Generic message fields come from MessageKind, and mail fields from EmailKind.
|
|
59
|
+
// Only what is Google's own is declared here.
|
|
54
60
|
export const googleMailMessageDefinition = {
|
|
55
61
|
name: 'GoogleMailMessage',
|
|
56
62
|
behavior: 'default',
|
|
57
63
|
interfaces: [
|
|
58
64
|
connectorModelIDs.ExternalEntity,
|
|
59
|
-
|
|
65
|
+
messagingModelIDs.EmailKind
|
|
60
66
|
],
|
|
61
67
|
schema: {
|
|
62
68
|
type: 'object',
|
|
63
69
|
properties: {
|
|
64
|
-
body: {
|
|
65
|
-
type: 'string'
|
|
66
|
-
},
|
|
67
|
-
sender: {
|
|
68
|
-
type: 'string',
|
|
69
|
-
minLength: 1,
|
|
70
|
-
maxLength: 320
|
|
71
|
-
},
|
|
72
|
-
subject: {
|
|
73
|
-
type: 'string'
|
|
74
|
-
},
|
|
75
70
|
snippet: {
|
|
76
71
|
type: 'string'
|
|
77
72
|
},
|
|
78
|
-
to: {
|
|
79
|
-
type: 'array',
|
|
80
|
-
title: 'To',
|
|
81
|
-
items: {
|
|
82
|
-
type: 'string'
|
|
83
|
-
}
|
|
84
|
-
},
|
|
85
|
-
cc: {
|
|
86
|
-
type: 'array',
|
|
87
|
-
title: 'Cc',
|
|
88
|
-
items: {
|
|
89
|
-
type: 'string'
|
|
90
|
-
}
|
|
91
|
-
},
|
|
92
73
|
labels: {
|
|
93
74
|
type: 'array',
|
|
94
75
|
title: 'Labels',
|
|
95
76
|
items: {
|
|
96
77
|
type: 'string'
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
sentAt: {
|
|
100
|
-
$ref: '#/definitions/DateTime'
|
|
78
|
+
},
|
|
79
|
+
maxItems: SHORT_LIST_MAX_ITEMS
|
|
101
80
|
},
|
|
102
81
|
unread: {
|
|
103
82
|
type: 'boolean'
|
|
@@ -107,14 +86,12 @@ export const googleMailMessageDefinition = {
|
|
|
107
86
|
title: 'Attachments',
|
|
108
87
|
items: {
|
|
109
88
|
$ref: '#/definitions/Attachment'
|
|
110
|
-
}
|
|
89
|
+
},
|
|
90
|
+
maxItems: SHORT_LIST_MAX_ITEMS
|
|
111
91
|
},
|
|
112
92
|
threadID: {
|
|
113
93
|
type: 'string'
|
|
114
94
|
},
|
|
115
|
-
thread: {
|
|
116
|
-
$ref: '#/definitions/ThreadDocID'
|
|
117
|
-
},
|
|
118
95
|
sourceService: {
|
|
119
96
|
$ref: '#/definitions/SourceService'
|
|
120
97
|
},
|
|
@@ -129,8 +106,6 @@ export const googleMailMessageDefinition = {
|
|
|
129
106
|
}
|
|
130
107
|
},
|
|
131
108
|
required: [
|
|
132
|
-
'body',
|
|
133
|
-
'sender',
|
|
134
109
|
'threadID',
|
|
135
110
|
'thread',
|
|
136
111
|
'sourceService',
|
|
@@ -140,7 +115,6 @@ export const googleMailMessageDefinition = {
|
|
|
140
115
|
additionalProperties: false,
|
|
141
116
|
definitions: {
|
|
142
117
|
...externalEntityDefinition.schema.definitions,
|
|
143
|
-
ThreadDocID: docIDSchema,
|
|
144
118
|
Attachment: {
|
|
145
119
|
type: 'object',
|
|
146
120
|
title: 'Attachment',
|
|
@@ -174,29 +148,22 @@ export const googleMailMessageDefinition = {
|
|
|
174
148
|
}
|
|
175
149
|
}
|
|
176
150
|
},
|
|
177
|
-
fieldsMeta: {
|
|
178
|
-
thread: {
|
|
179
|
-
type: 'document',
|
|
180
|
-
model: '#0'
|
|
181
|
-
}
|
|
182
|
-
},
|
|
151
|
+
fieldsMeta: {},
|
|
183
152
|
// lastSyncedAt is device-local sync bookkeeping; attachments carries the
|
|
184
153
|
// locally-materialized `blob` annotation (absent until an explicit import
|
|
185
154
|
// fills it), so the whole field stays outside the group rather than
|
|
186
155
|
// splitting a single top-level field between provider and device content.
|
|
187
156
|
providerMergePolicy: {
|
|
188
157
|
group: [
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
'subject',
|
|
158
|
+
...messageKindProviderFields.slice(0, 2),
|
|
159
|
+
...emailKindProviderFields.slice(0, 1),
|
|
192
160
|
'snippet',
|
|
193
|
-
|
|
194
|
-
'cc',
|
|
161
|
+
...emailKindProviderFields.slice(1),
|
|
195
162
|
'labels',
|
|
196
|
-
|
|
163
|
+
...messageKindProviderFields.slice(2, 3),
|
|
197
164
|
'unread',
|
|
198
165
|
'threadID',
|
|
199
|
-
|
|
166
|
+
...messageKindProviderFields.slice(3),
|
|
200
167
|
'sourceService',
|
|
201
168
|
'sourceID',
|
|
202
169
|
'sourceURL'
|
|
@@ -281,7 +248,7 @@ export const googleMailLabelDefinition = {
|
|
|
281
248
|
const builder = new GraphBuilder({
|
|
282
249
|
externalClusters: {
|
|
283
250
|
...connectorClusters,
|
|
284
|
-
...
|
|
251
|
+
...messagingClusters
|
|
285
252
|
}
|
|
286
253
|
});
|
|
287
254
|
builder.add(googleMailThreadDefinition); // #0
|
package/lib/provider.js
CHANGED
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
import { fetchOrAbsent, parseMaxAgeDays, resolveModelID } from '@kubun/connector';
|
|
2
|
+
import { mapConcurrent, retry } from '@sozai/async';
|
|
2
3
|
import { buildHistoryListURL, buildLabelsListURL, buildMessagesListURL, buildProfileURL, getHeader, getLabel, getMessage, getThread, mailAPIClient, mapLabelToEntity, mapMessageToEntity, mapThreadToEntity } from './api.js';
|
|
3
4
|
import { googleMailClusters } from './model.js';
|
|
5
|
+
// messages.get costs 5 quota units against Gmail's 250 units/s per-user limit.
|
|
6
|
+
const MESSAGE_FETCH_CONCURRENCY = 4;
|
|
7
|
+
// A burst can still trip Gmail's per-user rate limit; a limited message is
|
|
8
|
+
// retried on its own so the rest of the sync keeps going.
|
|
9
|
+
const MESSAGE_RATE_LIMIT_POLICY = {
|
|
10
|
+
maxAttempts: 6,
|
|
11
|
+
backoff: {
|
|
12
|
+
initialMs: 250,
|
|
13
|
+
multiplier: 2,
|
|
14
|
+
maxMs: 8000,
|
|
15
|
+
jitter: true
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
// 429, or Gmail's 403 rateLimitExceeded / userRateLimitExceeded.
|
|
19
|
+
function isRateLimited(error) {
|
|
20
|
+
const { httpStatus, message } = error ?? {};
|
|
21
|
+
if (httpStatus === 429) return true;
|
|
22
|
+
return httpStatus === 403 && typeof message === 'string' && /rate ?limit/i.test(message);
|
|
23
|
+
}
|
|
4
24
|
export const DEFAULT_QUERY = 'in:inbox';
|
|
5
25
|
const THREAD_MODEL_ID = resolveModelID(googleMailClusters, 'GoogleMailThread');
|
|
6
26
|
const MESSAGE_MODEL_ID = resolveModelID(googleMailClusters, 'GoogleMailMessage');
|
|
@@ -51,10 +71,20 @@ export function createGoogleMailProvider(config) {
|
|
|
51
71
|
// shared api.ts getMessage so both the bulk and targeted (fetchEntity)
|
|
52
72
|
// paths share one 404-tagging choke point.
|
|
53
73
|
async function getMessageResource(id, signal) {
|
|
54
|
-
return getMessage({
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
74
|
+
return retry(({ signal: attemptSignal })=>getMessage({
|
|
75
|
+
accessToken: config.credential.accessToken,
|
|
76
|
+
messageID: id,
|
|
77
|
+
fetch: fetchFn,
|
|
78
|
+
signal: attemptSignal
|
|
79
|
+
}), {
|
|
80
|
+
policy: MESSAGE_RATE_LIMIT_POLICY,
|
|
81
|
+
signal,
|
|
82
|
+
retryable: isRateLimited
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
function getMessageResources(ids, signal) {
|
|
86
|
+
return mapConcurrent(ids, (id, _index, callSignal)=>getMessageResource(id, callSignal), {
|
|
87
|
+
limit: MESSAGE_FETCH_CONCURRENCY,
|
|
58
88
|
signal
|
|
59
89
|
});
|
|
60
90
|
}
|
|
@@ -90,10 +120,7 @@ export function createGoogleMailProvider(config) {
|
|
|
90
120
|
const windowStart = windowStartSeconds(params.boundary, prior?.windowStart);
|
|
91
121
|
const query = applyAfter(config.query ?? DEFAULT_QUERY, windowStart);
|
|
92
122
|
const ids = await listMessageIDs(query, params.signal);
|
|
93
|
-
const messages =
|
|
94
|
-
for (const { id } of ids){
|
|
95
|
-
messages.push(await getMessageResource(id, params.signal));
|
|
96
|
-
}
|
|
123
|
+
const messages = await getMessageResources(ids.map(({ id })=>id), params.signal);
|
|
97
124
|
// Thread batch: one thread per distinct threadId, titled by its first message's subject.
|
|
98
125
|
const threadSubjects = new Map();
|
|
99
126
|
for (const m of messages){
|
|
@@ -176,10 +203,7 @@ export function createGoogleMailProvider(config) {
|
|
|
176
203
|
const survivingIDs = [
|
|
177
204
|
...addedIDs.keys()
|
|
178
205
|
].filter((id)=>!deletedIDs.has(id));
|
|
179
|
-
const messages =
|
|
180
|
-
for (const id of survivingIDs){
|
|
181
|
-
messages.push(await getMessageResource(id, params.signal));
|
|
182
|
-
}
|
|
206
|
+
const messages = await getMessageResources(survivingIDs, params.signal);
|
|
183
207
|
const threadSubjects = new Map();
|
|
184
208
|
for (const m of messages){
|
|
185
209
|
if (!threadSubjects.has(m.threadId)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kubun/connector-google-mail",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"keywords": [],
|
|
5
5
|
"license": "see LICENSE.md",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -15,14 +15,16 @@
|
|
|
15
15
|
"LICENSE.md"
|
|
16
16
|
],
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@kubun/connector": "^0.
|
|
19
|
-
"@kubun/credential-types": "^0.
|
|
20
|
-
"@kubun/hlc": "^0.
|
|
21
|
-
"@kubun/models": "^0.
|
|
22
|
-
"@kubun/
|
|
23
|
-
"@kubun/
|
|
24
|
-
"@kubun/
|
|
25
|
-
"@
|
|
18
|
+
"@kubun/connector": "^0.18.0",
|
|
19
|
+
"@kubun/credential-types": "^0.18.0",
|
|
20
|
+
"@kubun/hlc": "^0.18.0",
|
|
21
|
+
"@kubun/models-core": "^0.18.0",
|
|
22
|
+
"@kubun/models-messaging": "^0.18.0",
|
|
23
|
+
"@kubun/protocol": "^0.18.0",
|
|
24
|
+
"@kubun/provider-google": "^0.18.0",
|
|
25
|
+
"@kubun/scalars": "^0.18.0",
|
|
26
|
+
"@sozai/async": "^0.4.1",
|
|
27
|
+
"@sozai/codec": "^0.4.3"
|
|
26
28
|
},
|
|
27
29
|
"publishConfig": {
|
|
28
30
|
"access": "public"
|