@chronicle.app/gmail 0.0.0-stage → 0.4.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/LICENSE +21 -0
- package/README.md +63 -2
- package/dist/connectors/GmailApiExtractor.d.ts +76 -0
- package/dist/connectors/GmailApiExtractor.js +186 -0
- package/dist/connectors/GmailTakeoutExtractor.d.ts +52 -0
- package/dist/connectors/GmailTakeoutExtractor.js +118 -0
- package/dist/connectors/GmailTransformer.d.ts +16 -0
- package/dist/connectors/GmailTransformer.js +52 -0
- package/dist/filters.d.ts +32 -0
- package/dist/filters.js +54 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +6 -0
- package/dist/labels.d.ts +15 -0
- package/dist/labels.js +58 -0
- package/dist/types.d.ts +31 -0
- package/dist/types.js +1 -0
- package/package.json +88 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Andrew Louis
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,64 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @chronicle.app/gmail
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Your Gmail: messages, the threads Gmail groups them in, and their labels. Read live through the [Gmail API](https://developers.google.com/workspace/gmail/api/reference/rest), or from the mbox in a [Google Takeout](https://takeout.google.com).
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
chronicle auth login google
|
|
9
|
+
chronicle extract gmail
|
|
10
|
+
chronicle extract gmail --sent --since 2025-01-01
|
|
11
|
+
chronicle extract gmail --label Work,Starred
|
|
12
|
+
chronicle extract gmail --query "from:boss@example.com has:attachment"
|
|
13
|
+
chronicle extract gmail --strategy takeout --input "All mail Including Spam and Trash.mbox" --sent
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Sign in first: see [@chronicle.app/google](../../core/google/README.md), which walks you through making your own Google client. `--account` picks one of several signed-in accounts.
|
|
17
|
+
|
|
18
|
+
The API reads newest first. A Takeout reads in the order the mbox has, and needs no sign-in.
|
|
19
|
+
|
|
20
|
+
### What to extract
|
|
21
|
+
|
|
22
|
+
- `--sent`: only mail you sent.
|
|
23
|
+
- `--label Work,Starred`: only mail with all of these labels, yours or Gmail's own (Inbox, Starred, Important). Spam and Trash are left out unless you name them: `--label Spam`.
|
|
24
|
+
- `--query "<search>"`: any Gmail search, as in Gmail's search box: `from:`, `to:`, `has:attachment`, `-in:sent`. It needs the API.
|
|
25
|
+
- `--since` and `--until` narrow by when the mail was sent, with either.
|
|
26
|
+
|
|
27
|
+
## Schema
|
|
28
|
+
|
|
29
|
+
Messages are built by [@chronicle.app/email-core](../../core/email/README.md), the same way the email plugin builds them from an mbox, with Gmail's thread and labels added.
|
|
30
|
+
|
|
31
|
+
| Gmail thing | Chronicle node | Key | Properties |
|
|
32
|
+
| ----------------- | --------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
33
|
+
| Sending a message | `MessageAction` | `@type`, `sourceId` (the Message-ID) | `agent` (the sender), `object` (the `Message`), `timestamp` (the Date header) |
|
|
34
|
+
| A message | `Message` | `@type`, `sourceId` (the Message-ID) | `inAccount` (the account at the mailbox's address, from the API), `name` (subject), `body` (what was written, as text or Markdown), `author`, `recipient`, `inReplyTo`, `isPartOf` (its `Thread`), `tags` (labels) |
|
|
35
|
+
| A conversation | `Thread` | `@type`, `source`, `inAccount` (the Google account, from the API), `sourceId` (Gmail's thread ID) | |
|
|
36
|
+
| A person | `Agent` | `@type`, `source` (`email`), `handle` (their address, lowercased) | `alternateName` (each display name the mail gave it); you are `sameAs` `@me`, and your Google account from the API |
|
|
37
|
+
|
|
38
|
+
`SHAPES.md` sketches the output, generated from the tests.
|
|
39
|
+
|
|
40
|
+
### Decisions
|
|
41
|
+
|
|
42
|
+
**A message is its Message-ID, wherever it's read.** A message and the sending of it are keyed by its Message-ID, without the angle brackets around it, in the `email` namespace, the protocol's own, so the same message from the API, a Takeout, another mailbox, or an mbox is one node. The message is `inAccount` the account at the mailbox's address, an Agent keyed by that address in the `email` namespace, as any mail source of the same mailbox can say, so "messages in this account" is one query; a message in several of your accounts is in each. Gmail's own message ID isn't kept: it's only unique within the account (it's the time the message arrived there, and a counter), and the Message-ID already identifies the message. A thread is Gmail's: the Google account issued its ID, which is only unique within it, so the thread is `inAccount` the Google account (an Agent keyed by the address in the `google-account` namespace) and keyed within it. A person is their lowercased address in the `email` namespace, the identity other plugins link an address to, so a person seen in Gmail, any other mail, or a calendar is one node. A message without a Message-ID is identified by its sender, date, and subject.
|
|
43
|
+
|
|
44
|
+
**Threads link to Gmail, in their account.** The plugin declares a `deepLinks` template for its `Thread` nodes, `https://mail.google.com/mail/?authuser=<address>#all/<thread ID>`, so a host can open the conversation in Gmail. `authuser` picks the signed-in Google account by its address, so with several signed in it opens in the right one. A thread from a Takeout has no account to fill it with.
|
|
45
|
+
|
|
46
|
+
**Threads and labels come from Gmail either way.** The API gives a message's thread ID and label IDs; a Takeout writes the same thread ID in decimal (`X-GM-THRID`) and the labels by name (`X-Gmail-Labels`). Both give the same thread ID and the same label names, as Gmail shows them. Read state (Unread, Opened) changes as you read, so it isn't kept as a label.
|
|
47
|
+
|
|
48
|
+
**A Takeout names no account.** The mbox doesn't say which account it was exported from. A message's `Delivered-To` can be an alias, and mail you sent says only the address you sent it from, which can be a send-as address, so neither is the account. So a Takeout's messages are in no account, its threads are keyed by Gmail's thread ID alone, and you aren't linked to a Google account or to your contacts. Its messages are the API's messages, but its threads aren't the API's threads.
|
|
49
|
+
|
|
50
|
+
**A body is what was written, not what was sent.** It's plain text or Markdown, never HTML: the text part, or the HTML part converted to Markdown. Quoted history, signatures, newsletter footers, images, and tracking parameters are left out; see [@chronicle.app/email-core](../../core/email/README.md).
|
|
51
|
+
|
|
52
|
+
**Your contacts can link people.** With `--link-contacts` (and `chronicle auth login google --add contacts`), a person on one of your contacts is `sameAs` your contact: the entry in your Google account's contacts, keyed by its ID there and named as you saved it, which is `sameAs` the contact's other addresses and phone numbers, so the person who emails you is the person who texts or calls you. A Takeout doesn't: it can't say whose contacts they'd be.
|
|
53
|
+
|
|
54
|
+
**A draft isn't a message.** Drafts are left out, from the API and a Takeout alike: they were never sent.
|
|
55
|
+
|
|
56
|
+
**A display name is what a message called someone.** The name in a `From`, `To`, or `Cc` header is chosen by the sender or their address book, and a group or notification address carries a different one in nearly every message. So it's an `alternateName` of the address, never its `name`, and an address gathers one for each name it was shown with. A person's `name` comes from a source that keeps a record of them: the name you saved for a contact is that contact's `name`.
|
|
57
|
+
|
|
58
|
+
**Mail in Sent is yours.** Its sender is `sameAs` `@me`, whatever address it came from. Gmail also says the mailbox's own address (the API's profile, or the `Delivered-To` header in a Takeout), so you are `@me` as a recipient too.
|
|
59
|
+
|
|
60
|
+
**You are your Google account.** Wherever the API has you as `@me`, you are also `sameAs` the Google account the mailbox is: an Agent keyed by the mailbox's address in the `google-account` namespace, the node Google Calendar and Chrome link you to. A Takeout names no account, so it doesn't link you to one.
|
|
61
|
+
|
|
62
|
+
## Tests
|
|
63
|
+
|
|
64
|
+
The tests run against a fake Gmail API on 127.0.0.1 and a synthetic Takeout mbox in a temporary directory, both built from the same made-up messages, and pass the access token directly. They never read stored credentials or reach Google.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { Extractor, Record } from '@chronicle.app/etl';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import type { GmailRecord } from '../types.js';
|
|
4
|
+
import GmailTransformer from './GmailTransformer.js';
|
|
5
|
+
/**
|
|
6
|
+
* Your Gmail, newest first, through the Gmail API. Each message is fetched
|
|
7
|
+
* whole and parsed like any email, with Gmail's thread and labels.
|
|
8
|
+
*/
|
|
9
|
+
export declare class GmailApiExtractor extends Extractor<typeof GmailApiExtractor> {
|
|
10
|
+
static source: string;
|
|
11
|
+
static description: string;
|
|
12
|
+
static delivery: "api";
|
|
13
|
+
static strategy: string;
|
|
14
|
+
static recordTypes: string[];
|
|
15
|
+
static default: boolean;
|
|
16
|
+
static newestFirst: boolean;
|
|
17
|
+
static defaultTransformer: typeof GmailTransformer;
|
|
18
|
+
/** Swapped for a fake server in tests. */
|
|
19
|
+
static apiBaseURL: string;
|
|
20
|
+
/**
|
|
21
|
+
* The least time a batch takes. Fetching a message costs 5 of the 6,000
|
|
22
|
+
* units Gmail allows a user each minute, so a batch of 10 every half second
|
|
23
|
+
* stays inside it however long a run goes. Tests set it to 0.
|
|
24
|
+
*/
|
|
25
|
+
static batchIntervalMs: number;
|
|
26
|
+
static schema: z.ZodObject<{
|
|
27
|
+
since: z.ZodOptional<z.ZodDate>;
|
|
28
|
+
until: z.ZodOptional<z.ZodDate>;
|
|
29
|
+
limit: z.ZodOptional<z.ZodNumber>;
|
|
30
|
+
} & {
|
|
31
|
+
input: z.ZodOptional<z.ZodString>;
|
|
32
|
+
sent: z.ZodOptional<z.ZodBoolean>;
|
|
33
|
+
label: z.ZodOptional<z.ZodString>;
|
|
34
|
+
query: z.ZodOptional<z.ZodString>;
|
|
35
|
+
linkContacts: z.ZodOptional<z.ZodBoolean>;
|
|
36
|
+
account: z.ZodOptional<z.ZodString>;
|
|
37
|
+
accessToken: z.ZodOptional<z.ZodString>;
|
|
38
|
+
}, "strip", z.ZodTypeAny, {
|
|
39
|
+
account?: string | undefined;
|
|
40
|
+
input?: string | undefined;
|
|
41
|
+
since?: Date | undefined;
|
|
42
|
+
until?: Date | undefined;
|
|
43
|
+
limit?: number | undefined;
|
|
44
|
+
sent?: boolean | undefined;
|
|
45
|
+
label?: string | undefined;
|
|
46
|
+
query?: string | undefined;
|
|
47
|
+
linkContacts?: boolean | undefined;
|
|
48
|
+
accessToken?: string | undefined;
|
|
49
|
+
}, {
|
|
50
|
+
account?: string | undefined;
|
|
51
|
+
input?: string | undefined;
|
|
52
|
+
since?: Date | undefined;
|
|
53
|
+
until?: Date | undefined;
|
|
54
|
+
limit?: number | undefined;
|
|
55
|
+
sent?: boolean | undefined;
|
|
56
|
+
label?: string | undefined;
|
|
57
|
+
query?: string | undefined;
|
|
58
|
+
linkContacts?: boolean | undefined;
|
|
59
|
+
accessToken?: string | undefined;
|
|
60
|
+
}>;
|
|
61
|
+
private api;
|
|
62
|
+
private contacts;
|
|
63
|
+
private labelNames;
|
|
64
|
+
private owner;
|
|
65
|
+
keyOf(record: Record): string | null;
|
|
66
|
+
occurredAt(record: Record): Date | undefined;
|
|
67
|
+
private get options();
|
|
68
|
+
setup(): Promise<void>;
|
|
69
|
+
determineCount(): Promise<number | null>;
|
|
70
|
+
extract(): AsyncGenerator<Record>;
|
|
71
|
+
/** The label IDs every message must have: `--label`, and Sent for `--sent`. */
|
|
72
|
+
private labelIds;
|
|
73
|
+
private recordOf;
|
|
74
|
+
recordToString(data?: GmailRecord): string;
|
|
75
|
+
}
|
|
76
|
+
//# sourceMappingURL=GmailApiExtractor.d.ts.map
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { Extractor } from '@chronicle.app/etl';
|
|
2
|
+
import { ContactDirectory, GoogleApi, contactOptions, googleAccountOptions, } from '@chronicle.app/google';
|
|
3
|
+
import { identityOf, parseMessage } from '@chronicle.app/email-core';
|
|
4
|
+
import { EXIT_CODES, ExtractorError } from '@chronicle.app/logging';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { filterOptions, gmailSearch, includesSpamTrash, narrows, wantedLabels, } from '../filters.js';
|
|
7
|
+
import { labelId, labelName } from '../labels.js';
|
|
8
|
+
import GmailTransformer from './GmailTransformer.js';
|
|
9
|
+
/** Messages fetched at once. */
|
|
10
|
+
const CONCURRENCY = 10;
|
|
11
|
+
/**
|
|
12
|
+
* Your Gmail, newest first, through the Gmail API. Each message is fetched
|
|
13
|
+
* whole and parsed like any email, with Gmail's thread and labels.
|
|
14
|
+
*/
|
|
15
|
+
export class GmailApiExtractor extends Extractor {
|
|
16
|
+
static source = 'gmail';
|
|
17
|
+
static description = 'Messages in your Gmail';
|
|
18
|
+
static delivery = 'api';
|
|
19
|
+
static strategy = 'api';
|
|
20
|
+
static recordTypes = ['messages'];
|
|
21
|
+
static default = true;
|
|
22
|
+
static newestFirst = true;
|
|
23
|
+
static defaultTransformer = GmailTransformer;
|
|
24
|
+
/** Swapped for a fake server in tests. */
|
|
25
|
+
static apiBaseURL = 'https://gmail.googleapis.com/gmail/v1';
|
|
26
|
+
/**
|
|
27
|
+
* The least time a batch takes. Fetching a message costs 5 of the 6,000
|
|
28
|
+
* units Gmail allows a user each minute, so a batch of 10 every half second
|
|
29
|
+
* stays inside it however long a run goes. Tests set it to 0.
|
|
30
|
+
*/
|
|
31
|
+
static batchIntervalMs = 500;
|
|
32
|
+
static schema = Extractor.schema.extend({
|
|
33
|
+
...googleAccountOptions,
|
|
34
|
+
...contactOptions,
|
|
35
|
+
...filterOptions,
|
|
36
|
+
// A Takeout's; listed here too, since a source's flags are shared.
|
|
37
|
+
input: z.string().optional().describe('Path to the Gmail mbox from Google Takeout'),
|
|
38
|
+
});
|
|
39
|
+
api;
|
|
40
|
+
contacts = ContactDirectory.empty;
|
|
41
|
+
labelNames = new Map();
|
|
42
|
+
owner = null;
|
|
43
|
+
keyOf(record) {
|
|
44
|
+
return identityOf(record.data.mail);
|
|
45
|
+
}
|
|
46
|
+
occurredAt(record) {
|
|
47
|
+
const { mail, gmail } = record.data;
|
|
48
|
+
const at = gmail.receivedAt ?? mail.date;
|
|
49
|
+
return at ? new Date(at) : undefined;
|
|
50
|
+
}
|
|
51
|
+
get options() {
|
|
52
|
+
return this.config;
|
|
53
|
+
}
|
|
54
|
+
async setup() {
|
|
55
|
+
await super.setup();
|
|
56
|
+
const { account, accessToken } = this.options;
|
|
57
|
+
this.api = new GoogleApi({
|
|
58
|
+
service: 'gmail',
|
|
59
|
+
baseURL: GmailApiExtractor.apiBaseURL,
|
|
60
|
+
account,
|
|
61
|
+
accessToken,
|
|
62
|
+
});
|
|
63
|
+
await this.api.initialize();
|
|
64
|
+
const [profile, labels] = await Promise.all([
|
|
65
|
+
this.api.get('/users/me/profile'),
|
|
66
|
+
this.api.get('/users/me/labels'),
|
|
67
|
+
]);
|
|
68
|
+
this.owner = profile.emailAddress ?? null;
|
|
69
|
+
for (const label of labels.labels ?? [])
|
|
70
|
+
this.labelNames.set(label.id, label.name);
|
|
71
|
+
const { directory, missing } = await ContactDirectory.load({
|
|
72
|
+
account,
|
|
73
|
+
accessToken,
|
|
74
|
+
linkContacts: this.options.linkContacts,
|
|
75
|
+
});
|
|
76
|
+
this.contacts = directory;
|
|
77
|
+
if (missing) {
|
|
78
|
+
this.hint('Your contacts aren’t linked to these people', {
|
|
79
|
+
action: 'Run `chronicle auth login google --add contacts` to link them.',
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
async determineCount() {
|
|
84
|
+
// Only the whole mailbox has a count without listing it.
|
|
85
|
+
if (narrows(this.options))
|
|
86
|
+
return null;
|
|
87
|
+
const profile = await this.api.get('/users/me/profile');
|
|
88
|
+
const total = profile.messagesTotal ?? null;
|
|
89
|
+
const limit = this.getEffectiveLimit();
|
|
90
|
+
return total !== null && limit !== null ? Math.min(total, limit) : total;
|
|
91
|
+
}
|
|
92
|
+
async *extract() {
|
|
93
|
+
const ids = this.api.pages('/users/me/messages', {
|
|
94
|
+
q: gmailSearch(this.options),
|
|
95
|
+
labelIds: this.labelIds(),
|
|
96
|
+
includeSpamTrash: includesSpamTrash(this.options),
|
|
97
|
+
maxResults: 500,
|
|
98
|
+
}, 'messages');
|
|
99
|
+
let count = 0;
|
|
100
|
+
let unidentified = 0;
|
|
101
|
+
let batch = [];
|
|
102
|
+
// Fetched a batch at a time, and yielded in the order Gmail listed them.
|
|
103
|
+
const flush = async function* () {
|
|
104
|
+
const started = Date.now();
|
|
105
|
+
// A message deleted between the listing and the fetch is just gone.
|
|
106
|
+
const messages = await Promise.all(batch.map(id => this.api.get(`/users/me/messages/${id}`, { format: 'raw' }).catch(error => {
|
|
107
|
+
if (error?.code === 'not-found')
|
|
108
|
+
return null;
|
|
109
|
+
throw error;
|
|
110
|
+
})));
|
|
111
|
+
const wait = GmailApiExtractor.batchIntervalMs - (Date.now() - started);
|
|
112
|
+
if (wait > 0)
|
|
113
|
+
await new Promise(resolve => setTimeout(resolve, wait));
|
|
114
|
+
batch = [];
|
|
115
|
+
for (const message of messages) {
|
|
116
|
+
// A draft hasn't been sent: it isn't a message yet.
|
|
117
|
+
if (!message || message.labelIds?.includes('DRAFT'))
|
|
118
|
+
continue;
|
|
119
|
+
const record = await this.recordOf(message);
|
|
120
|
+
if (!record) {
|
|
121
|
+
unidentified++;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
yield this.createRecord(record, { recordType: 'messages' });
|
|
125
|
+
if (this.shouldStopExtracting(++count))
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
return false;
|
|
129
|
+
}.bind(this);
|
|
130
|
+
try {
|
|
131
|
+
for await (const { id } of ids) {
|
|
132
|
+
batch.push(id);
|
|
133
|
+
if (batch.length < CONCURRENCY)
|
|
134
|
+
continue;
|
|
135
|
+
if (yield* flush())
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (batch.length > 0)
|
|
139
|
+
yield* flush();
|
|
140
|
+
}
|
|
141
|
+
finally {
|
|
142
|
+
if (unidentified > 0) {
|
|
143
|
+
this.logger.warn(`Skipped ${unidentified} message(s) with no Message-ID and no From + Date to key on`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/** The label IDs every message must have: `--label`, and Sent for `--sent`. */
|
|
148
|
+
labelIds() {
|
|
149
|
+
const ids = wantedLabels(this.options).map(name => {
|
|
150
|
+
const id = labelId(name, this.labelNames);
|
|
151
|
+
if (!id) {
|
|
152
|
+
throw new ExtractorError(`Gmail has no label "${name}"`, {
|
|
153
|
+
code: 'unknown-label',
|
|
154
|
+
exitCode: EXIT_CODES.usage,
|
|
155
|
+
hint: `Your labels are ${[...this.labelNames.values()].sort().join(', ')}.`,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
return id;
|
|
159
|
+
});
|
|
160
|
+
return ids.length > 0 ? ids : undefined;
|
|
161
|
+
}
|
|
162
|
+
async recordOf(message) {
|
|
163
|
+
const mail = await parseMessage(Buffer.from(message.raw, 'base64url'));
|
|
164
|
+
// Nothing real to identify it by: a made-up id would mint a new message each run.
|
|
165
|
+
if (identityOf(mail) === null)
|
|
166
|
+
return null;
|
|
167
|
+
return {
|
|
168
|
+
mail,
|
|
169
|
+
gmail: {
|
|
170
|
+
id: message.id,
|
|
171
|
+
threadId: message.threadId,
|
|
172
|
+
labels: (message.labelIds ?? [])
|
|
173
|
+
.map(id => labelName(id, this.labelNames))
|
|
174
|
+
.filter((name) => name !== undefined),
|
|
175
|
+
receivedAt: new Date(Number(message.internalDate)).toISOString(),
|
|
176
|
+
// The profile's address is the account the API read, and yours.
|
|
177
|
+
owner: this.owner,
|
|
178
|
+
account: this.owner,
|
|
179
|
+
},
|
|
180
|
+
contacts: this.contacts.linksFor([mail.from, ...mail.to, ...mail.cc, ...mail.bcc].flatMap(person => person ? [person.address] : [])),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
recordToString(data) {
|
|
184
|
+
return data?.mail?.subject || 'gmail.messages';
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Extractor, Record } from '@chronicle.app/etl';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import type { GmailRecord } from '../types.js';
|
|
4
|
+
import GmailTransformer from './GmailTransformer.js';
|
|
5
|
+
/**
|
|
6
|
+
* Your Gmail from a Google Takeout: the mbox it exports (`All mail Including
|
|
7
|
+
* Spam and Trash.mbox`). Each message keeps the thread and labels Gmail wrote
|
|
8
|
+
* into its `X-GM-THRID` and `X-Gmail-Labels` headers, so it becomes the same
|
|
9
|
+
* record the API makes.
|
|
10
|
+
*/
|
|
11
|
+
export declare class GmailTakeoutExtractor extends Extractor<typeof GmailTakeoutExtractor> {
|
|
12
|
+
static source: string;
|
|
13
|
+
static description: string;
|
|
14
|
+
static delivery: "export";
|
|
15
|
+
static strategy: string;
|
|
16
|
+
static recordTypes: string[];
|
|
17
|
+
static defaultTransformer: typeof GmailTransformer;
|
|
18
|
+
static schema: z.ZodObject<{
|
|
19
|
+
since: z.ZodOptional<z.ZodDate>;
|
|
20
|
+
until: z.ZodOptional<z.ZodDate>;
|
|
21
|
+
limit: z.ZodOptional<z.ZodNumber>;
|
|
22
|
+
} & {
|
|
23
|
+
input: z.ZodOptional<z.ZodString>;
|
|
24
|
+
sent: z.ZodOptional<z.ZodBoolean>;
|
|
25
|
+
label: z.ZodOptional<z.ZodString>;
|
|
26
|
+
query: z.ZodOptional<z.ZodString>;
|
|
27
|
+
}, "strip", z.ZodTypeAny, {
|
|
28
|
+
input?: string | undefined;
|
|
29
|
+
since?: Date | undefined;
|
|
30
|
+
until?: Date | undefined;
|
|
31
|
+
limit?: number | undefined;
|
|
32
|
+
sent?: boolean | undefined;
|
|
33
|
+
label?: string | undefined;
|
|
34
|
+
query?: string | undefined;
|
|
35
|
+
}, {
|
|
36
|
+
input?: string | undefined;
|
|
37
|
+
since?: Date | undefined;
|
|
38
|
+
until?: Date | undefined;
|
|
39
|
+
limit?: number | undefined;
|
|
40
|
+
sent?: boolean | undefined;
|
|
41
|
+
label?: string | undefined;
|
|
42
|
+
query?: string | undefined;
|
|
43
|
+
}>;
|
|
44
|
+
keyOf(record: Record): string | null;
|
|
45
|
+
occurredAt(record: Record): Date | undefined;
|
|
46
|
+
private get options();
|
|
47
|
+
private get input();
|
|
48
|
+
determineCount(): Promise<number | null>;
|
|
49
|
+
extract(): AsyncGenerator<Record>;
|
|
50
|
+
recordToString(data?: GmailRecord): string;
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=GmailTakeoutExtractor.d.ts.map
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { Extractor, InputNotFound } from '@chronicle.app/etl';
|
|
2
|
+
import { countMbox, identityOf, parseMessage, readMbox } from '@chronicle.app/email-core';
|
|
3
|
+
import { EXIT_CODES, ExtractorError } from '@chronicle.app/logging';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { filterOptions, matches, narrows } from '../filters.js';
|
|
6
|
+
import { hexId, takeoutLabels } from '../labels.js';
|
|
7
|
+
import GmailTransformer from './GmailTransformer.js';
|
|
8
|
+
/**
|
|
9
|
+
* Your Gmail from a Google Takeout: the mbox it exports (`All mail Including
|
|
10
|
+
* Spam and Trash.mbox`). Each message keeps the thread and labels Gmail wrote
|
|
11
|
+
* into its `X-GM-THRID` and `X-Gmail-Labels` headers, so it becomes the same
|
|
12
|
+
* record the API makes.
|
|
13
|
+
*/
|
|
14
|
+
export class GmailTakeoutExtractor extends Extractor {
|
|
15
|
+
static source = 'gmail';
|
|
16
|
+
static description = 'Messages in a Google Takeout of your Gmail';
|
|
17
|
+
static delivery = 'export';
|
|
18
|
+
static strategy = 'takeout';
|
|
19
|
+
static recordTypes = ['messages'];
|
|
20
|
+
static defaultTransformer = GmailTransformer;
|
|
21
|
+
// `input` is checked when it's read rather than by the schema: a source's
|
|
22
|
+
// flags are shared, so a schema-required flag here would also gate the API.
|
|
23
|
+
static schema = Extractor.schema.extend({
|
|
24
|
+
...filterOptions,
|
|
25
|
+
input: z.string().optional().describe('Path to the Gmail mbox from Google Takeout'),
|
|
26
|
+
});
|
|
27
|
+
keyOf(record) {
|
|
28
|
+
return identityOf(record.data.mail);
|
|
29
|
+
}
|
|
30
|
+
occurredAt(record) {
|
|
31
|
+
const { date } = record.data.mail;
|
|
32
|
+
return date ? new Date(date) : undefined;
|
|
33
|
+
}
|
|
34
|
+
get options() {
|
|
35
|
+
return this.config;
|
|
36
|
+
}
|
|
37
|
+
get input() {
|
|
38
|
+
if (this.options.query) {
|
|
39
|
+
throw new ExtractorError('A Gmail search needs the API', {
|
|
40
|
+
code: 'unsupported-flag',
|
|
41
|
+
exitCode: EXIT_CODES.usage,
|
|
42
|
+
hint: 'Use `--label` or `--sent` with a Takeout instead.',
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
if (!this.options.input) {
|
|
46
|
+
throw new InputNotFound('No Takeout mbox given', {
|
|
47
|
+
hint: 'Pass the Gmail mbox from your Takeout with `--input <path>`.',
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return this.options.input;
|
|
51
|
+
}
|
|
52
|
+
async determineCount() {
|
|
53
|
+
if (narrows(this.options))
|
|
54
|
+
return null;
|
|
55
|
+
try {
|
|
56
|
+
const total = await countMbox(this.input);
|
|
57
|
+
const limit = this.getEffectiveLimit();
|
|
58
|
+
return limit === null ? total : Math.min(total, limit);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
async *extract() {
|
|
65
|
+
let count = 0;
|
|
66
|
+
let unidentified = 0;
|
|
67
|
+
let undated = 0;
|
|
68
|
+
for await (const { raw, envelopeDate } of readMbox(this.input)) {
|
|
69
|
+
const mail = await parseMessage(raw);
|
|
70
|
+
// No Date header: when its `From ` line says Gmail received it.
|
|
71
|
+
mail.date ??= envelopeDate;
|
|
72
|
+
if (!mail.date) {
|
|
73
|
+
undated++;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (identityOf(mail) === null) {
|
|
77
|
+
unidentified++;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const labels = takeoutLabels(mail.headers['x-gmail-labels']);
|
|
81
|
+
// A draft hasn't been sent: it isn't a message yet.
|
|
82
|
+
if (labels.includes('Drafts') || !matches(mail, labels, this.options))
|
|
83
|
+
continue;
|
|
84
|
+
const record = {
|
|
85
|
+
mail,
|
|
86
|
+
gmail: {
|
|
87
|
+
id: hexId(mail.headers['x-gm-msgid']),
|
|
88
|
+
threadId: hexId(mail.headers['x-gm-thrid']),
|
|
89
|
+
labels,
|
|
90
|
+
receivedAt: envelopeDate,
|
|
91
|
+
// Gmail writes the mailbox's own address on every message it
|
|
92
|
+
// delivers; a message you sent has none, but it's from you.
|
|
93
|
+
owner: addressIn(mail.headers['delivered-to']) ??
|
|
94
|
+
(labels.includes('Sent') ? (mail.from?.address.toLowerCase() ?? null) : null),
|
|
95
|
+
// Those addresses can be aliases or send-as addresses, not the
|
|
96
|
+
// account, and nothing in the mbox names the account it came from.
|
|
97
|
+
account: null,
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
yield this.createRecord(record, { recordType: 'messages' });
|
|
101
|
+
if (this.shouldStopExtracting(++count))
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
if (undated > 0) {
|
|
105
|
+
this.logger.warn(`Skipped ${undated} message(s) with no date to place them by`);
|
|
106
|
+
}
|
|
107
|
+
if (unidentified > 0) {
|
|
108
|
+
this.logger.warn(`Skipped ${unidentified} message(s) with no Message-ID and no From + Date to key on`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
recordToString(data) {
|
|
112
|
+
return data?.mail?.subject || 'gmail.messages';
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** The address in a header like `Delivered-To: me@example.com`. */
|
|
116
|
+
function addressIn(header) {
|
|
117
|
+
return header?.match(/[^\s<>,"]+@[^\s<>,"]+/)?.[0]?.toLowerCase() ?? null;
|
|
118
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { ChronicleTransformer, Record } from '@chronicle.app/etl';
|
|
2
|
+
import { ActionAndChildren } from '@chronicle.app/schema';
|
|
3
|
+
/**
|
|
4
|
+
* A Gmail message is keyed by its Message-ID, so copies in other mailboxes
|
|
5
|
+
* and mbox files are one message. When the extractor names the account (the
|
|
6
|
+
* API does, a Takeout doesn't), the message is `inAccount` its address, as
|
|
7
|
+
* any mail source of the same mailbox says, and you are `sameAs` the Google
|
|
8
|
+
* account at that address. It `isPartOf` its `Thread`, whose ID the Google
|
|
9
|
+
* account issued, so the thread is `inAccount` it and keyed within it. Its
|
|
10
|
+
* labels are its `tags`. Mail in Sent is yours. A person on one of your
|
|
11
|
+
* contacts is `sameAs` the contact's other addresses and phone numbers.
|
|
12
|
+
*/
|
|
13
|
+
export default class GmailTransformer extends ChronicleTransformer {
|
|
14
|
+
transform(record: Record): Promise<ActionAndChildren[]>;
|
|
15
|
+
}
|
|
16
|
+
//# sourceMappingURL=GmailTransformer.d.ts.map
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { ChronicleTransformer } from '@chronicle.app/etl';
|
|
2
|
+
import { contactIdentities, googleAccount } from '@chronicle.app/google';
|
|
3
|
+
import { emailAccount, messageAction } from '@chronicle.app/email-core';
|
|
4
|
+
/**
|
|
5
|
+
* A Gmail message is keyed by its Message-ID, so copies in other mailboxes
|
|
6
|
+
* and mbox files are one message. When the extractor names the account (the
|
|
7
|
+
* API does, a Takeout doesn't), the message is `inAccount` its address, as
|
|
8
|
+
* any mail source of the same mailbox says, and you are `sameAs` the Google
|
|
9
|
+
* account at that address. It `isPartOf` its `Thread`, whose ID the Google
|
|
10
|
+
* account issued, so the thread is `inAccount` it and keyed within it. Its
|
|
11
|
+
* labels are its `tags`. Mail in Sent is yours. A person on one of your
|
|
12
|
+
* contacts is `sameAs` the contact's other addresses and phone numbers.
|
|
13
|
+
*/
|
|
14
|
+
export default class GmailTransformer extends ChronicleTransformer {
|
|
15
|
+
async transform(record) {
|
|
16
|
+
if (record.extraction.recordType !== 'messages')
|
|
17
|
+
return [];
|
|
18
|
+
const { mail, gmail, contacts } = record.data;
|
|
19
|
+
// The account at the mailbox's address, which any mail source can name,
|
|
20
|
+
// and the Google account, which issues Gmail's thread IDs: they're only
|
|
21
|
+
// unique within it, so they're keyed in it.
|
|
22
|
+
const account = gmail.account ? emailAccount(gmail.account) : undefined;
|
|
23
|
+
const google = gmail.account ? googleAccount(gmail.account) : undefined;
|
|
24
|
+
const thread = gmail.threadId
|
|
25
|
+
? {
|
|
26
|
+
'@type': 'Thread',
|
|
27
|
+
source: 'gmail',
|
|
28
|
+
sourceId: gmail.threadId,
|
|
29
|
+
...(google
|
|
30
|
+
? {
|
|
31
|
+
'@key': ['@type', 'source', 'inAccount[*].handle', 'sourceId'],
|
|
32
|
+
inAccount: [google],
|
|
33
|
+
}
|
|
34
|
+
: { '@key': ['@type', 'source', 'sourceId'] }),
|
|
35
|
+
}
|
|
36
|
+
: undefined;
|
|
37
|
+
return [
|
|
38
|
+
messageAction(mail, {
|
|
39
|
+
me: gmail.owner ? [gmail.owner] : [],
|
|
40
|
+
sentByMe: gmail.labels.includes('Sent'),
|
|
41
|
+
...(gmail.receivedAt && { receivedAt: new Date(gmail.receivedAt) }),
|
|
42
|
+
...(thread && { thread }),
|
|
43
|
+
tags: gmail.labels,
|
|
44
|
+
...(account && { account }),
|
|
45
|
+
// You are the Google account at the mailbox's address.
|
|
46
|
+
meIdentities: google ? [google] : [],
|
|
47
|
+
// Your contacts link a person to their other addresses and numbers.
|
|
48
|
+
identitiesOf: address => contactIdentities(address, contacts?.[address.toLowerCase()], gmail.account),
|
|
49
|
+
}),
|
|
50
|
+
];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { MailMessage } from '@chronicle.app/email-core';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
/**
|
|
4
|
+
* What to extract. `--sent` and `--label` work with the API and a Takeout
|
|
5
|
+
* alike; anything else is a Gmail search, which needs the API.
|
|
6
|
+
*/
|
|
7
|
+
export declare const filterOptions: {
|
|
8
|
+
sent: z.ZodOptional<z.ZodBoolean>;
|
|
9
|
+
label: z.ZodOptional<z.ZodString>;
|
|
10
|
+
query: z.ZodOptional<z.ZodString>;
|
|
11
|
+
};
|
|
12
|
+
export interface Filters {
|
|
13
|
+
sent?: boolean;
|
|
14
|
+
label?: string;
|
|
15
|
+
query?: string;
|
|
16
|
+
since?: Date;
|
|
17
|
+
until?: Date;
|
|
18
|
+
}
|
|
19
|
+
/** Whether a run asks for anything less than the whole mailbox. */
|
|
20
|
+
export declare const narrows: (filters: Filters) => boolean;
|
|
21
|
+
/** The label names every message must have: `--label`, and Sent for `--sent`. */
|
|
22
|
+
export declare function wantedLabels(filters: Filters): string[];
|
|
23
|
+
/** Spam and Trash are read only when a run names them. */
|
|
24
|
+
export declare const includesSpamTrash: (filters: Filters) => boolean;
|
|
25
|
+
/**
|
|
26
|
+
* The Gmail search for a run: its `--query`, and the window (`after:` and
|
|
27
|
+
* `before:` take epoch seconds). Labels go by ID instead, in `labelIds`.
|
|
28
|
+
*/
|
|
29
|
+
export declare function gmailSearch(filters: Filters): string | undefined;
|
|
30
|
+
/** Whether a message read from a Takeout passes the filters, given its labels. */
|
|
31
|
+
export declare function matches(mail: MailMessage, labels: string[], filters: Filters): boolean;
|
|
32
|
+
//# sourceMappingURL=filters.d.ts.map
|
package/dist/filters.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/**
|
|
3
|
+
* What to extract. `--sent` and `--label` work with the API and a Takeout
|
|
4
|
+
* alike; anything else is a Gmail search, which needs the API.
|
|
5
|
+
*/
|
|
6
|
+
export const filterOptions = {
|
|
7
|
+
sent: z.boolean().optional().describe('Only mail you sent'),
|
|
8
|
+
label: z
|
|
9
|
+
.string()
|
|
10
|
+
.optional()
|
|
11
|
+
.describe('Only mail with all of these labels, comma-separated, like "Work" or "Starred". Spam and Trash are left out unless named.'),
|
|
12
|
+
query: z
|
|
13
|
+
.string()
|
|
14
|
+
.optional()
|
|
15
|
+
.describe('A Gmail search, as in Gmail’s search box, like "from:boss has:attachment"'),
|
|
16
|
+
};
|
|
17
|
+
/** Whether a run asks for anything less than the whole mailbox. */
|
|
18
|
+
export const narrows = (filters) => Boolean(filters.sent || filters.label || filters.query || filters.since || filters.until);
|
|
19
|
+
/** The label names every message must have: `--label`, and Sent for `--sent`. */
|
|
20
|
+
export function wantedLabels(filters) {
|
|
21
|
+
const labels = (filters.label ?? '')
|
|
22
|
+
.split(',')
|
|
23
|
+
.map(label => label.trim())
|
|
24
|
+
.filter(Boolean);
|
|
25
|
+
return filters.sent ? [...labels, 'Sent'] : labels;
|
|
26
|
+
}
|
|
27
|
+
/** Spam and Trash are read only when a run names them. */
|
|
28
|
+
export const includesSpamTrash = (filters) => wantedLabels(filters).some(label => /^(spam|trash)$/i.test(label));
|
|
29
|
+
/**
|
|
30
|
+
* The Gmail search for a run: its `--query`, and the window (`after:` and
|
|
31
|
+
* `before:` take epoch seconds). Labels go by ID instead, in `labelIds`.
|
|
32
|
+
*/
|
|
33
|
+
export function gmailSearch(filters) {
|
|
34
|
+
const terms = [
|
|
35
|
+
filters.query,
|
|
36
|
+
filters.since && `after:${Math.floor(filters.since.getTime() / 1000)}`,
|
|
37
|
+
filters.until && `before:${Math.floor(filters.until.getTime() / 1000)}`,
|
|
38
|
+
].filter(Boolean);
|
|
39
|
+
return terms.length > 0 ? terms.join(' ') : undefined;
|
|
40
|
+
}
|
|
41
|
+
/** Whether a message read from a Takeout passes the filters, given its labels. */
|
|
42
|
+
export function matches(mail, labels, filters) {
|
|
43
|
+
const has = new Set(labels.map(label => label.toLowerCase()));
|
|
44
|
+
if (!includesSpamTrash(filters) && (has.has('spam') || has.has('trash')))
|
|
45
|
+
return false;
|
|
46
|
+
if (!wantedLabels(filters).every(label => has.has(label.toLowerCase())))
|
|
47
|
+
return false;
|
|
48
|
+
const sent = mail.date ? new Date(mail.date) : undefined;
|
|
49
|
+
if (filters.since && (!sent || sent < filters.since))
|
|
50
|
+
return false;
|
|
51
|
+
if (filters.until && (!sent || sent >= filters.until))
|
|
52
|
+
return false;
|
|
53
|
+
return true;
|
|
54
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import '@chronicle.app/google';
|
|
2
|
+
export { GmailApiExtractor } from './connectors/GmailApiExtractor.js';
|
|
3
|
+
export { GmailTakeoutExtractor } from './connectors/GmailTakeoutExtractor.js';
|
|
4
|
+
export { default as GmailTransformer } from './connectors/GmailTransformer.js';
|
|
5
|
+
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Registers the Google sign-in, so `chronicle auth login google` finds it.
|
|
2
|
+
import '@chronicle.app/google';
|
|
3
|
+
// Export extractors for plugin scanning
|
|
4
|
+
export { GmailApiExtractor } from './connectors/GmailApiExtractor.js';
|
|
5
|
+
export { GmailTakeoutExtractor } from './connectors/GmailTakeoutExtractor.js';
|
|
6
|
+
export { default as GmailTransformer } from './connectors/GmailTransformer.js';
|
package/dist/labels.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** A label's name from its API ID: Gmail's own by its shown name, yours from `names`. */
|
|
2
|
+
export declare function labelName(id: string, names: Map<string, string>): string | undefined;
|
|
3
|
+
/** A label's API ID from its name, either kind, ignoring case. */
|
|
4
|
+
export declare function labelId(name: string, names: Map<string, string>): string | undefined;
|
|
5
|
+
/**
|
|
6
|
+
* The labels in a Takeout message's `X-Gmail-Labels` header: comma-separated,
|
|
7
|
+
* a name with a comma in quotes. Read state is left out.
|
|
8
|
+
*/
|
|
9
|
+
export declare function takeoutLabels(header: string | undefined): string[];
|
|
10
|
+
/**
|
|
11
|
+
* Gmail's thread and message IDs: the API writes them in hex, a Takeout's
|
|
12
|
+
* `X-GM-THRID` and `X-GM-MSGID` in decimal. Hex either way, so they match.
|
|
13
|
+
*/
|
|
14
|
+
export declare function hexId(decimal: string | undefined): string | null;
|
|
15
|
+
//# sourceMappingURL=labels.d.ts.map
|
package/dist/labels.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gmail's own labels by the names Gmail shows, the same names a Takeout
|
|
3
|
+
* writes in `X-Gmail-Labels`, so a message has the same labels either way.
|
|
4
|
+
* Read state (Unread, Opened) changes as you read, so it isn't a label here.
|
|
5
|
+
*/
|
|
6
|
+
const SYSTEM_LABELS = {
|
|
7
|
+
INBOX: 'Inbox',
|
|
8
|
+
SENT: 'Sent',
|
|
9
|
+
DRAFT: 'Drafts',
|
|
10
|
+
STARRED: 'Starred',
|
|
11
|
+
IMPORTANT: 'Important',
|
|
12
|
+
SPAM: 'Spam',
|
|
13
|
+
TRASH: 'Trash',
|
|
14
|
+
CHAT: 'Chat',
|
|
15
|
+
CATEGORY_PERSONAL: 'Category Personal',
|
|
16
|
+
CATEGORY_SOCIAL: 'Category Social',
|
|
17
|
+
CATEGORY_PROMOTIONS: 'Category Promotions',
|
|
18
|
+
CATEGORY_UPDATES: 'Category Updates',
|
|
19
|
+
CATEGORY_FORUMS: 'Category Forums',
|
|
20
|
+
};
|
|
21
|
+
const READ_STATE = new Set(['UNREAD', 'Unread', 'Opened']);
|
|
22
|
+
/** A label's name from its API ID: Gmail's own by its shown name, yours from `names`. */
|
|
23
|
+
export function labelName(id, names) {
|
|
24
|
+
if (READ_STATE.has(id))
|
|
25
|
+
return undefined;
|
|
26
|
+
return SYSTEM_LABELS[id] ?? names.get(id);
|
|
27
|
+
}
|
|
28
|
+
/** A label's API ID from its name, either kind, ignoring case. */
|
|
29
|
+
export function labelId(name, names) {
|
|
30
|
+
const wanted = name.toLowerCase();
|
|
31
|
+
for (const [id, shown] of Object.entries(SYSTEM_LABELS)) {
|
|
32
|
+
if (shown.toLowerCase() === wanted || id.toLowerCase() === wanted)
|
|
33
|
+
return id;
|
|
34
|
+
}
|
|
35
|
+
for (const [id, shown] of names)
|
|
36
|
+
if (shown.toLowerCase() === wanted)
|
|
37
|
+
return id;
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The labels in a Takeout message's `X-Gmail-Labels` header: comma-separated,
|
|
42
|
+
* a name with a comma in quotes. Read state is left out.
|
|
43
|
+
*/
|
|
44
|
+
export function takeoutLabels(header) {
|
|
45
|
+
const labels = (header ?? '').match(/"[^"]*"|[^,]+/g) ?? [];
|
|
46
|
+
return labels
|
|
47
|
+
.map(label => label.trim().replaceAll(/^"|"$/g, ''))
|
|
48
|
+
.filter(label => label && !READ_STATE.has(label));
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Gmail's thread and message IDs: the API writes them in hex, a Takeout's
|
|
52
|
+
* `X-GM-THRID` and `X-GM-MSGID` in decimal. Hex either way, so they match.
|
|
53
|
+
*/
|
|
54
|
+
export function hexId(decimal) {
|
|
55
|
+
if (!decimal || !/^\d+$/.test(decimal.trim()))
|
|
56
|
+
return null;
|
|
57
|
+
return BigInt(decimal.trim()).toString(16);
|
|
58
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ContactLinks } from '@chronicle.app/google';
|
|
2
|
+
import type { MailMessage } from '@chronicle.app/email-core';
|
|
3
|
+
/** One extracted record: the message, and what Gmail knows about it. */
|
|
4
|
+
export interface GmailRecord {
|
|
5
|
+
mail: MailMessage;
|
|
6
|
+
gmail: {
|
|
7
|
+
/** Gmail's message ID, in hex. */
|
|
8
|
+
id: string | null;
|
|
9
|
+
/** Gmail's thread ID, in hex: the API's and a Takeout's agree. */
|
|
10
|
+
threadId: string | null;
|
|
11
|
+
/** Label names, as Gmail shows them. */
|
|
12
|
+
labels: string[];
|
|
13
|
+
/** When Gmail received it: the API's internal date, or a Takeout's `From ` line. */
|
|
14
|
+
receivedAt: string | null;
|
|
15
|
+
/**
|
|
16
|
+
* Your address on the message: the API's profile, or a Takeout's
|
|
17
|
+
* Delivered-To, or the sender of mail in Sent.
|
|
18
|
+
*/
|
|
19
|
+
owner: string | null;
|
|
20
|
+
/**
|
|
21
|
+
* The Google account the mailbox is, by its address: the API's profile.
|
|
22
|
+
* A Takeout doesn't say which account it came from, so it has none.
|
|
23
|
+
*/
|
|
24
|
+
account: string | null;
|
|
25
|
+
};
|
|
26
|
+
/** What your contacts link each address on it to (API only). */
|
|
27
|
+
contacts?: {
|
|
28
|
+
[address: string]: ContactLinks;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
//# sourceMappingURL=types.d.ts.map
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,90 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chronicle.app/gmail",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"main": "./dist/index.js",
|
|
5
|
+
"types": "./dist/index.d.ts",
|
|
6
|
+
"files": [
|
|
7
|
+
"dist/**/*.js",
|
|
8
|
+
"dist/**/*.d.ts"
|
|
9
|
+
],
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc -b tsconfig.lib.json",
|
|
12
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
13
|
+
"lint": "eslint src --ext .ts,.js",
|
|
14
|
+
"test": "node --test src/*.test.js",
|
|
15
|
+
"shapes": "node src/shapes.test.js --update"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"chronicle",
|
|
19
|
+
"google",
|
|
20
|
+
"gmail",
|
|
21
|
+
"email"
|
|
22
|
+
],
|
|
23
|
+
"author": "Andrew Louis",
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"type": "module",
|
|
26
|
+
"description": "Chronicle plugin for your Gmail: messages, threads, and labels",
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@chronicle.app/google": "0.4.0",
|
|
29
|
+
"@chronicle.app/logging": "0.4.0",
|
|
30
|
+
"@chronicle.app/email-core": "0.4.0",
|
|
31
|
+
"zod": "^3.24.1"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@chronicle.app/etl": "0.4.0",
|
|
35
|
+
"@chronicle.app/schema": "0.4.0",
|
|
36
|
+
"@chronicle.app/tsconfig": "0.4.0",
|
|
37
|
+
"@types/node": "^22.10.1",
|
|
38
|
+
"typescript": "^5.7.2"
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://chronicle.app",
|
|
41
|
+
"repository": {
|
|
42
|
+
"type": "git",
|
|
43
|
+
"url": "https://github.com/chronicle-app/chronicle.git",
|
|
44
|
+
"directory": "plugins/gmail"
|
|
45
|
+
},
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public",
|
|
48
|
+
"registry": "https://registry.npmjs.org/"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"@chronicle.app/etl": ">=0.1.0 <1.0.0",
|
|
52
|
+
"@chronicle.app/schema": ">=0.1.0 <1.0.0"
|
|
53
|
+
},
|
|
54
|
+
"engines": {
|
|
55
|
+
"node": ">=22.13.0"
|
|
56
|
+
},
|
|
57
|
+
"exports": {
|
|
58
|
+
".": {
|
|
59
|
+
"types": "./dist/index.d.ts",
|
|
60
|
+
"import": "./dist/index.js"
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
"chronicle": {
|
|
64
|
+
"plugin": true,
|
|
65
|
+
"sources": {
|
|
66
|
+
"gmail": {
|
|
67
|
+
"strategies": {
|
|
68
|
+
"api": {
|
|
69
|
+
"delivery": "api",
|
|
70
|
+
"recordTypes": [
|
|
71
|
+
"messages"
|
|
72
|
+
],
|
|
73
|
+
"default": true
|
|
74
|
+
},
|
|
75
|
+
"takeout": {
|
|
76
|
+
"delivery": "export",
|
|
77
|
+
"recordTypes": [
|
|
78
|
+
"messages"
|
|
79
|
+
]
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
"deepLinks": {
|
|
85
|
+
"gmail": {
|
|
86
|
+
"Thread": "https://mail.google.com/mail/?authuser={inAccount[*].handle}#all/{sourceId}"
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|