@1kbirds/chidori-mock-gmail 0.1.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/README.md +85 -0
- package/dist/api.d.ts +14 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +139 -0
- package/dist/client.d.ts +228 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +50 -0
- package/dist/errors.d.ts +19 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +25 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/seed.d.ts +6 -0
- package/dist/seed.d.ts.map +1 -0
- package/dist/seed.js +127 -0
- package/dist/service.d.ts +41 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +372 -0
- package/dist/state.d.ts +22 -0
- package/dist/state.d.ts.map +1 -0
- package/dist/state.js +139 -0
- package/dist/types.d.ts +123 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/dist/ui/GmailMockApp.d.ts +7 -0
- package/dist/ui/GmailMockApp.d.ts.map +1 -0
- package/dist/ui/GmailMockApp.js +93 -0
- package/dist/ui/dev.d.ts +2 -0
- package/dist/ui/dev.d.ts.map +1 -0
- package/dist/ui/dev.js +11 -0
- package/dist/ui/index.d.ts +3 -0
- package/dist/ui/index.d.ts.map +1 -0
- package/dist/ui/index.js +1 -0
- package/dist/ui/styles.css +340 -0
- package/package.json +56 -0
- package/src/__tests__/service.test.ts +120 -0
- package/src/api.ts +157 -0
- package/src/client.ts +54 -0
- package/src/errors.ts +29 -0
- package/src/index.ts +12 -0
- package/src/seed.ts +143 -0
- package/src/service.ts +405 -0
- package/src/state.ts +159 -0
- package/src/types.ts +149 -0
- package/src/ui/GmailMockApp.tsx +236 -0
- package/src/ui/dev.tsx +16 -0
- package/src/ui/index.ts +2 -0
- package/src/ui/styles.css +340 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { GmailMock } from "./service";
|
|
2
|
+
import type { GmailLabel, GmailMessage, GmailModifyRequest, GmailSearchOptions, GmailSendRequest } from "./types";
|
|
3
|
+
|
|
4
|
+
function response<T>(data: T, status = 200, statusText = "OK") {
|
|
5
|
+
return { data, status, statusText, headers: {} };
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function createGmailClient(mock: GmailMock) {
|
|
9
|
+
return {
|
|
10
|
+
users: {
|
|
11
|
+
getProfile: async ({ userId: _userId = "me" }: { userId?: string } = {}) => response(mock.getProfile()),
|
|
12
|
+
labels: {
|
|
13
|
+
list: async ({ userId: _userId = "me" }: { userId?: string } = {}) => response(mock.listLabels()),
|
|
14
|
+
get: async ({ id }: { userId?: string; id: string }) => response(mock.getLabel(id)),
|
|
15
|
+
create: async ({ requestBody }: { userId?: string; requestBody: Partial<GmailLabel> }) => response(mock.createLabel(requestBody)),
|
|
16
|
+
update: async ({ id, requestBody }: { userId?: string; id: string; requestBody: Partial<GmailLabel> }) => response(mock.updateLabel(id, requestBody)),
|
|
17
|
+
delete: async ({ id }: { userId?: string; id: string }) => {
|
|
18
|
+
mock.deleteLabel(id);
|
|
19
|
+
return response(undefined, 204, "No Content");
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
messages: {
|
|
23
|
+
list: async (params: GmailSearchOptions & { userId?: string } = {}) => response(mock.listMessages(params)),
|
|
24
|
+
get: async ({ id }: { userId?: string; id: string }) => response(mock.getMessage(id)),
|
|
25
|
+
insert: async ({ requestBody }: { userId?: string; requestBody: Partial<GmailMessage> | GmailSendRequest }) => response(mock.insertMessage(requestBody)),
|
|
26
|
+
send: async ({ requestBody }: { userId?: string; requestBody: GmailSendRequest }) => response(mock.send(requestBody)),
|
|
27
|
+
modify: async ({ id, requestBody }: { userId?: string; id: string; requestBody: GmailModifyRequest }) => response(mock.modifyMessage(id, requestBody)),
|
|
28
|
+
trash: async ({ id }: { userId?: string; id: string }) => response(mock.trashMessage(id)),
|
|
29
|
+
untrash: async ({ id }: { userId?: string; id: string }) => response(mock.untrashMessage(id)),
|
|
30
|
+
delete: async ({ id }: { userId?: string; id: string }) => {
|
|
31
|
+
mock.deleteMessage(id);
|
|
32
|
+
return response(undefined, 204, "No Content");
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
threads: {
|
|
36
|
+
list: async (params: GmailSearchOptions & { userId?: string } = {}) => response(mock.listThreads(params)),
|
|
37
|
+
get: async ({ id }: { userId?: string; id: string }) => response(mock.getThread(id)),
|
|
38
|
+
modify: async ({ id, requestBody }: { userId?: string; id: string; requestBody: GmailModifyRequest }) => response(mock.modifyThread(id, requestBody)),
|
|
39
|
+
trash: async ({ id }: { userId?: string; id: string }) => response(mock.trashThread(id)),
|
|
40
|
+
},
|
|
41
|
+
drafts: {
|
|
42
|
+
list: async () => response(mock.listDrafts()),
|
|
43
|
+
get: async ({ id }: { userId?: string; id: string }) => response(mock.getDraft(id)),
|
|
44
|
+
create: async ({ requestBody }: { userId?: string; requestBody: GmailSendRequest }) => response(mock.createDraft(requestBody)),
|
|
45
|
+
update: async ({ id, requestBody }: { userId?: string; id: string; requestBody: GmailSendRequest }) => response(mock.updateDraft(id, requestBody)),
|
|
46
|
+
send: async ({ id }: { userId?: string; id: string }) => response(mock.sendDraft(id)),
|
|
47
|
+
delete: async ({ id }: { userId?: string; id: string }) => {
|
|
48
|
+
mock.deleteDraft(id);
|
|
49
|
+
return response(undefined, 204, "No Content");
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export class GmailMockError extends Error {
|
|
2
|
+
readonly status: number;
|
|
3
|
+
readonly reason: string;
|
|
4
|
+
|
|
5
|
+
constructor(status: number, reason: string, message: string) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = "GmailMockError";
|
|
8
|
+
this.status = status;
|
|
9
|
+
this.reason = reason;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
toJSON() {
|
|
13
|
+
return {
|
|
14
|
+
error: {
|
|
15
|
+
code: this.status,
|
|
16
|
+
message: this.message,
|
|
17
|
+
errors: [{ domain: "global", reason: this.reason, message: this.message }],
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function badRequest(message: string): GmailMockError {
|
|
24
|
+
return new GmailMockError(400, "badRequest", message);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function notFound(message: string): GmailMockError {
|
|
28
|
+
return new GmailMockError(404, "notFound", message);
|
|
29
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { handleGmailApiRequest } from "./api";
|
|
2
|
+
export type { GmailApiRequest, GmailApiResponse } from "./api";
|
|
3
|
+
export { createGmailClient } from "./client";
|
|
4
|
+
export { GmailMockError } from "./errors";
|
|
5
|
+
export { decodeBase64Url, encodeBase64Url, GmailMock } from "./service";
|
|
6
|
+
import { GmailMock } from "./service";
|
|
7
|
+
export { demoDrafts, demoMessages, demoSeed, demoUser } from "./seed";
|
|
8
|
+
export type * from "./types";
|
|
9
|
+
|
|
10
|
+
export function createGmailMock(...args: ConstructorParameters<typeof GmailMock>): GmailMock {
|
|
11
|
+
return new GmailMock(...args);
|
|
12
|
+
}
|
package/src/seed.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { encodeBase64Url } from "./service";
|
|
2
|
+
import { defaultLabels } from "./state";
|
|
3
|
+
import type { GmailDraft, GmailMessage, GmailSeed, GmailUser } from "./types";
|
|
4
|
+
|
|
5
|
+
export const demoUser: GmailUser = {
|
|
6
|
+
id: "me",
|
|
7
|
+
emailAddress: "ada@example.com",
|
|
8
|
+
displayName: "Ada Lovelace",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
function message(input: {
|
|
12
|
+
id: string;
|
|
13
|
+
threadId?: string;
|
|
14
|
+
labels: string[];
|
|
15
|
+
from: string;
|
|
16
|
+
to: string;
|
|
17
|
+
subject: string;
|
|
18
|
+
body: string;
|
|
19
|
+
date: string;
|
|
20
|
+
attachments?: Array<{ id: string; filename: string; mimeType: string; size: number; data?: string }>;
|
|
21
|
+
}): GmailMessage {
|
|
22
|
+
const threadId = input.threadId ?? input.id;
|
|
23
|
+
return {
|
|
24
|
+
id: input.id,
|
|
25
|
+
threadId,
|
|
26
|
+
labelIds: input.labels,
|
|
27
|
+
snippet: input.body.replace(/\s+/g, " ").slice(0, 160),
|
|
28
|
+
historyId: "1",
|
|
29
|
+
internalDate: String(new Date(input.date).getTime()),
|
|
30
|
+
sizeEstimate: input.body.length,
|
|
31
|
+
attachments: input.attachments,
|
|
32
|
+
payload: {
|
|
33
|
+
partId: "",
|
|
34
|
+
mimeType: "text/plain",
|
|
35
|
+
filename: "",
|
|
36
|
+
headers: [
|
|
37
|
+
{ name: "From", value: input.from },
|
|
38
|
+
{ name: "To", value: input.to },
|
|
39
|
+
{ name: "Subject", value: input.subject },
|
|
40
|
+
{ name: "Date", value: new Date(input.date).toUTCString() },
|
|
41
|
+
],
|
|
42
|
+
body: {
|
|
43
|
+
size: input.body.length,
|
|
44
|
+
data: encodeBase64Url(input.body),
|
|
45
|
+
},
|
|
46
|
+
parts: input.attachments?.map((attachment, index) => ({
|
|
47
|
+
partId: String(index + 1),
|
|
48
|
+
mimeType: attachment.mimeType,
|
|
49
|
+
filename: attachment.filename,
|
|
50
|
+
headers: [{ name: "Content-Disposition", value: `attachment; filename="${attachment.filename}"` }],
|
|
51
|
+
body: { attachmentId: attachment.id, size: attachment.size, data: attachment.data },
|
|
52
|
+
})),
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const demoMessages: GmailMessage[] = [
|
|
58
|
+
message({
|
|
59
|
+
id: "msg_schedule_request",
|
|
60
|
+
labels: ["INBOX", "UNREAD", "IMPORTANT", "CATEGORY_PRIMARY"],
|
|
61
|
+
from: "morgan@example.com",
|
|
62
|
+
to: "ada@example.com",
|
|
63
|
+
subject: "Can we meet this week?",
|
|
64
|
+
body: "Hi Ada, can we meet this week to review the launch timeline? Tuesday or Wednesday afternoon would work for me.",
|
|
65
|
+
date: "2026-06-01T16:10:00.000Z",
|
|
66
|
+
}),
|
|
67
|
+
message({
|
|
68
|
+
id: "msg_launch_brief",
|
|
69
|
+
labels: ["INBOX", "CATEGORY_PRIMARY"],
|
|
70
|
+
from: "grace@example.com",
|
|
71
|
+
to: "ada@example.com",
|
|
72
|
+
subject: "Launch brief attached",
|
|
73
|
+
body: "Attached is the current launch brief. Please review the owner list before our planning session.",
|
|
74
|
+
date: "2026-06-01T15:40:00.000Z",
|
|
75
|
+
attachments: [{ id: "att_launch_brief", filename: "launch-brief.txt", mimeType: "text/plain", size: 128, data: encodeBase64Url("Launch brief fixture") }],
|
|
76
|
+
}),
|
|
77
|
+
message({
|
|
78
|
+
id: "msg_reply_thread_1",
|
|
79
|
+
threadId: "thread_customer",
|
|
80
|
+
labels: ["INBOX", "CATEGORY_PRIMARY"],
|
|
81
|
+
from: "customer@example.com",
|
|
82
|
+
to: "ada@example.com",
|
|
83
|
+
subject: "Re: Proposal questions",
|
|
84
|
+
body: "Thanks for the proposal. Could you clarify implementation timing and support coverage?",
|
|
85
|
+
date: "2026-05-31T18:00:00.000Z",
|
|
86
|
+
}),
|
|
87
|
+
message({
|
|
88
|
+
id: "msg_reply_thread_2",
|
|
89
|
+
threadId: "thread_customer",
|
|
90
|
+
labels: ["SENT"],
|
|
91
|
+
from: "ada@example.com",
|
|
92
|
+
to: "customer@example.com",
|
|
93
|
+
subject: "Re: Proposal questions",
|
|
94
|
+
body: "Happy to clarify. Implementation typically takes two weeks and support coverage is included.",
|
|
95
|
+
date: "2026-05-31T19:00:00.000Z",
|
|
96
|
+
}),
|
|
97
|
+
message({
|
|
98
|
+
id: "msg_newsletter",
|
|
99
|
+
labels: ["TRASH"],
|
|
100
|
+
from: "newsletter@example.com",
|
|
101
|
+
to: "ada@example.com",
|
|
102
|
+
subject: "Weekly digest",
|
|
103
|
+
body: "A weekly digest that has been moved to trash.",
|
|
104
|
+
date: "2026-05-30T12:00:00.000Z",
|
|
105
|
+
}),
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
export const demoDrafts: GmailDraft[] = [
|
|
109
|
+
{
|
|
110
|
+
id: "draft_followup",
|
|
111
|
+
message: message({
|
|
112
|
+
id: "msg_draft_followup",
|
|
113
|
+
labels: ["DRAFT"],
|
|
114
|
+
from: "ada@example.com",
|
|
115
|
+
to: "morgan@example.com",
|
|
116
|
+
subject: "Re: Can we meet this week?",
|
|
117
|
+
body: "Hi Morgan, Tuesday afternoon works. I can send a calendar invite.",
|
|
118
|
+
date: "2026-06-01T16:20:00.000Z",
|
|
119
|
+
}),
|
|
120
|
+
},
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
export const demoSeed: GmailSeed = {
|
|
124
|
+
users: [demoUser],
|
|
125
|
+
currentUserId: demoUser.id,
|
|
126
|
+
labels: [
|
|
127
|
+
...defaultLabels(),
|
|
128
|
+
{
|
|
129
|
+
id: "Label_customers",
|
|
130
|
+
name: "Customers",
|
|
131
|
+
type: "user",
|
|
132
|
+
messageListVisibility: "show",
|
|
133
|
+
labelListVisibility: "labelShow",
|
|
134
|
+
color: { textColor: "#ffffff", backgroundColor: "#0b8043" },
|
|
135
|
+
messagesTotal: 0,
|
|
136
|
+
messagesUnread: 0,
|
|
137
|
+
threadsTotal: 0,
|
|
138
|
+
threadsUnread: 0,
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
messages: demoMessages,
|
|
142
|
+
drafts: demoDrafts,
|
|
143
|
+
};
|
package/src/service.ts
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import { badRequest, notFound } from "./errors";
|
|
2
|
+
import { applySnapshot, bumpHistory, clone, createStore, emit, snapshot, type GmailMockStore } from "./state";
|
|
3
|
+
import type {
|
|
4
|
+
GmailDraft,
|
|
5
|
+
GmailLabel,
|
|
6
|
+
GmailListResponse,
|
|
7
|
+
GmailMessage,
|
|
8
|
+
GmailMessagePart,
|
|
9
|
+
GmailMockOptions,
|
|
10
|
+
GmailModifyRequest,
|
|
11
|
+
GmailProfile,
|
|
12
|
+
GmailSearchOptions,
|
|
13
|
+
GmailSendRequest,
|
|
14
|
+
GmailSnapshot,
|
|
15
|
+
GmailThread,
|
|
16
|
+
} from "./types";
|
|
17
|
+
|
|
18
|
+
export class GmailMock {
|
|
19
|
+
readonly store: GmailMockStore;
|
|
20
|
+
private readonly initialSnapshot: GmailSnapshot;
|
|
21
|
+
|
|
22
|
+
constructor(options: GmailMockOptions = {}) {
|
|
23
|
+
this.store = createStore(options);
|
|
24
|
+
this.initialSnapshot = snapshot(this.store);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
subscribe(listener: () => void): () => void {
|
|
28
|
+
this.store.listeners.add(listener);
|
|
29
|
+
return () => this.store.listeners.delete(listener);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
snapshot(): GmailSnapshot {
|
|
33
|
+
return snapshot(this.store);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
reset(seed?: GmailSnapshot): void {
|
|
37
|
+
applySnapshot(this.store, seed ?? this.initialSnapshot);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
getProfile(): GmailProfile {
|
|
41
|
+
const user = this.currentUser();
|
|
42
|
+
return {
|
|
43
|
+
emailAddress: user.emailAddress,
|
|
44
|
+
messagesTotal: this.visibleMessages({ includeSpamTrash: true }).length,
|
|
45
|
+
threadsTotal: this.listThreads({ includeSpamTrash: true }).threads?.length ?? 0,
|
|
46
|
+
historyId: String(this.store.historyId),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
listLabels(): GmailListResponse<GmailLabel> {
|
|
51
|
+
return {
|
|
52
|
+
resultSizeEstimate: this.store.labels.size,
|
|
53
|
+
labels: Array.from(this.store.labels.values()).map(clone),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
getLabel(id: string): GmailLabel {
|
|
58
|
+
const label = this.store.labels.get(id);
|
|
59
|
+
if (!label) throw notFound(`Label not found: ${id}`);
|
|
60
|
+
return clone(label);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
createLabel(input: Partial<GmailLabel>): GmailLabel {
|
|
64
|
+
if (!input.name) throw badRequest("Missing required field: name");
|
|
65
|
+
const id = input.id ?? `Label_${this.store.nextId("label")}`;
|
|
66
|
+
if (this.store.labels.has(id)) throw badRequest(`Label already exists: ${id}`);
|
|
67
|
+
const label: GmailLabel = {
|
|
68
|
+
id,
|
|
69
|
+
name: input.name,
|
|
70
|
+
type: "user",
|
|
71
|
+
messageListVisibility: input.messageListVisibility ?? "show",
|
|
72
|
+
labelListVisibility: input.labelListVisibility ?? "labelShow",
|
|
73
|
+
color: input.color,
|
|
74
|
+
messagesTotal: 0,
|
|
75
|
+
messagesUnread: 0,
|
|
76
|
+
threadsTotal: 0,
|
|
77
|
+
threadsUnread: 0,
|
|
78
|
+
};
|
|
79
|
+
this.store.labels.set(id, label);
|
|
80
|
+
bumpHistory(this.store);
|
|
81
|
+
emit(this.store);
|
|
82
|
+
return clone(label);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
updateLabel(id: string, input: Partial<GmailLabel>): GmailLabel {
|
|
86
|
+
const existing = this.getLabel(id);
|
|
87
|
+
const next = { ...existing, ...input, id, type: existing.type };
|
|
88
|
+
this.store.labels.set(id, next);
|
|
89
|
+
bumpHistory(this.store);
|
|
90
|
+
emit(this.store);
|
|
91
|
+
return clone(next);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
deleteLabel(id: string): void {
|
|
95
|
+
const existing = this.getLabel(id);
|
|
96
|
+
if (existing.type === "system") throw badRequest("System labels cannot be deleted");
|
|
97
|
+
this.store.labels.delete(id);
|
|
98
|
+
for (const message of this.store.messages.values()) {
|
|
99
|
+
message.labelIds = message.labelIds.filter((labelId) => labelId !== id);
|
|
100
|
+
}
|
|
101
|
+
bumpHistory(this.store);
|
|
102
|
+
emit(this.store);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
listMessages(options: GmailSearchOptions = {}): GmailListResponse<Pick<GmailMessage, "id" | "threadId">> {
|
|
106
|
+
const messages = this.visibleMessages(options).map((message) => ({ id: message.id, threadId: message.threadId }));
|
|
107
|
+
return { resultSizeEstimate: messages.length, messages };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
getMessage(id: string): GmailMessage {
|
|
111
|
+
return clone(this.requireMessage(id));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
insertMessage(input: Partial<GmailMessage> | GmailSendRequest): GmailMessage {
|
|
115
|
+
const message = this.buildMessage(input, {
|
|
116
|
+
labels: normalizeLabels((input as Partial<GmailMessage>).labelIds ?? ["INBOX", "UNREAD", "CATEGORY_PRIMARY"]),
|
|
117
|
+
threadId: (input as Partial<GmailMessage>).threadId,
|
|
118
|
+
});
|
|
119
|
+
this.store.messages.set(message.id, message);
|
|
120
|
+
emit(this.store);
|
|
121
|
+
return clone(message);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
send(input: GmailSendRequest): GmailMessage {
|
|
125
|
+
const message = this.buildMessage(input, {
|
|
126
|
+
labels: ["SENT"],
|
|
127
|
+
threadId: input.message?.threadId,
|
|
128
|
+
fromCurrentUser: true,
|
|
129
|
+
});
|
|
130
|
+
this.store.messages.set(message.id, message);
|
|
131
|
+
emit(this.store);
|
|
132
|
+
return clone(message);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
modifyMessage(id: string, request: GmailModifyRequest): GmailMessage {
|
|
136
|
+
const message = this.requireMessage(id);
|
|
137
|
+
message.labelIds = modifyLabels(message.labelIds, request);
|
|
138
|
+
message.historyId = bumpHistory(this.store);
|
|
139
|
+
emit(this.store);
|
|
140
|
+
return clone(message);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
trashMessage(id: string): GmailMessage {
|
|
144
|
+
const message = this.requireMessage(id);
|
|
145
|
+
message.labelIds = modifyLabels(message.labelIds, { addLabelIds: ["TRASH"], removeLabelIds: ["INBOX", "SPAM"] });
|
|
146
|
+
message.historyId = bumpHistory(this.store);
|
|
147
|
+
emit(this.store);
|
|
148
|
+
return clone(message);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
untrashMessage(id: string): GmailMessage {
|
|
152
|
+
const message = this.requireMessage(id);
|
|
153
|
+
message.labelIds = modifyLabels(message.labelIds, { addLabelIds: ["INBOX"], removeLabelIds: ["TRASH"] });
|
|
154
|
+
message.historyId = bumpHistory(this.store);
|
|
155
|
+
emit(this.store);
|
|
156
|
+
return clone(message);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
deleteMessage(id: string): void {
|
|
160
|
+
this.requireMessage(id);
|
|
161
|
+
this.store.messages.delete(id);
|
|
162
|
+
for (const [draftId, draft] of this.store.drafts) {
|
|
163
|
+
if (draft.message.id === id) this.store.drafts.delete(draftId);
|
|
164
|
+
}
|
|
165
|
+
bumpHistory(this.store);
|
|
166
|
+
emit(this.store);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
listThreads(options: GmailSearchOptions = {}): GmailListResponse<Pick<GmailThread, "id" | "historyId" | "snippet">> {
|
|
170
|
+
const threads = groupThreads(this.visibleMessages(options)).map((thread) => ({
|
|
171
|
+
id: thread.id,
|
|
172
|
+
historyId: thread.historyId,
|
|
173
|
+
snippet: thread.snippet,
|
|
174
|
+
}));
|
|
175
|
+
return { resultSizeEstimate: threads.length, threads };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
getThread(id: string): GmailThread {
|
|
179
|
+
const messages = Array.from(this.store.messages.values())
|
|
180
|
+
.filter((message) => message.threadId === id)
|
|
181
|
+
.sort((a, b) => Number(a.internalDate) - Number(b.internalDate));
|
|
182
|
+
if (messages.length === 0) throw notFound(`Thread not found: ${id}`);
|
|
183
|
+
return clone(groupThreads(messages)[0]);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
modifyThread(id: string, request: GmailModifyRequest): GmailThread {
|
|
187
|
+
const thread = this.getThread(id);
|
|
188
|
+
for (const message of thread.messages) {
|
|
189
|
+
this.modifyMessage(message.id, request);
|
|
190
|
+
}
|
|
191
|
+
return this.getThread(id);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
trashThread(id: string): GmailThread {
|
|
195
|
+
const thread = this.getThread(id);
|
|
196
|
+
for (const message of thread.messages) this.trashMessage(message.id);
|
|
197
|
+
return this.getThread(id);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
listDrafts(): GmailListResponse<Pick<GmailDraft, "id" | "message">> {
|
|
201
|
+
const drafts = Array.from(this.store.drafts.values()).map((draft) => ({ id: draft.id, message: clone(draft.message) }));
|
|
202
|
+
return { resultSizeEstimate: drafts.length, drafts };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
getDraft(id: string): GmailDraft {
|
|
206
|
+
const draft = this.store.drafts.get(id);
|
|
207
|
+
if (!draft) throw notFound(`Draft not found: ${id}`);
|
|
208
|
+
return clone(draft);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
createDraft(input: GmailSendRequest): GmailDraft {
|
|
212
|
+
const message = this.buildMessage(input, { labels: ["DRAFT"], threadId: input.message?.threadId, fromCurrentUser: true });
|
|
213
|
+
const draft: GmailDraft = { id: this.store.nextId("draft"), message };
|
|
214
|
+
this.store.drafts.set(draft.id, draft);
|
|
215
|
+
this.store.messages.set(message.id, message);
|
|
216
|
+
emit(this.store);
|
|
217
|
+
return clone(draft);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
updateDraft(id: string, input: GmailSendRequest): GmailDraft {
|
|
221
|
+
const existing = this.getDraft(id);
|
|
222
|
+
const message = this.buildMessage(input, { labels: ["DRAFT"], threadId: input.message?.threadId ?? existing.message.threadId, fromCurrentUser: true, id: existing.message.id });
|
|
223
|
+
const draft = { id, message };
|
|
224
|
+
this.store.drafts.set(id, draft);
|
|
225
|
+
this.store.messages.set(message.id, message);
|
|
226
|
+
emit(this.store);
|
|
227
|
+
return clone(draft);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
sendDraft(id: string): GmailMessage {
|
|
231
|
+
const draft = this.getDraft(id);
|
|
232
|
+
this.store.drafts.delete(id);
|
|
233
|
+
const message = { ...draft.message, labelIds: ["SENT"], historyId: bumpHistory(this.store), internalDate: String(this.store.now().getTime()) };
|
|
234
|
+
this.store.messages.set(message.id, message);
|
|
235
|
+
emit(this.store);
|
|
236
|
+
return clone(message);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
deleteDraft(id: string): void {
|
|
240
|
+
const draft = this.getDraft(id);
|
|
241
|
+
this.store.drafts.delete(id);
|
|
242
|
+
this.store.messages.delete(draft.message.id);
|
|
243
|
+
bumpHistory(this.store);
|
|
244
|
+
emit(this.store);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private buildMessage(input: Partial<GmailMessage> | GmailSendRequest, options: { labels: string[]; threadId?: string; fromCurrentUser?: boolean; id?: string }): GmailMessage {
|
|
248
|
+
const now = this.store.now();
|
|
249
|
+
const user = this.currentUser();
|
|
250
|
+
const id = options.id ?? (input as Partial<GmailMessage>).id ?? this.store.nextId("msg");
|
|
251
|
+
const threadId = options.threadId ?? id;
|
|
252
|
+
const headers = buildHeaders(input, user.emailAddress, options.fromCurrentUser);
|
|
253
|
+
const bodyText = extractBody(input);
|
|
254
|
+
const payload: GmailMessagePart = (input as Partial<GmailMessage>).payload ?? {
|
|
255
|
+
partId: "",
|
|
256
|
+
mimeType: "text/plain",
|
|
257
|
+
filename: "",
|
|
258
|
+
headers,
|
|
259
|
+
body: {
|
|
260
|
+
size: bodyText.length,
|
|
261
|
+
data: encodeBase64Url(bodyText),
|
|
262
|
+
},
|
|
263
|
+
parts: ((input as GmailSendRequest).attachments ?? []).map((attachment, index) => ({
|
|
264
|
+
partId: String(index + 1),
|
|
265
|
+
mimeType: attachment.mimeType,
|
|
266
|
+
filename: attachment.filename,
|
|
267
|
+
headers: [
|
|
268
|
+
{ name: "Content-Type", value: attachment.mimeType },
|
|
269
|
+
{ name: "Content-Disposition", value: `attachment; filename="${attachment.filename}"` },
|
|
270
|
+
],
|
|
271
|
+
body: { attachmentId: attachment.id, size: attachment.size, data: attachment.data },
|
|
272
|
+
})),
|
|
273
|
+
};
|
|
274
|
+
const message: GmailMessage = {
|
|
275
|
+
id,
|
|
276
|
+
threadId,
|
|
277
|
+
labelIds: normalizeLabels(options.labels),
|
|
278
|
+
snippet: makeSnippet(bodyText || headerValue(headers, "Subject")),
|
|
279
|
+
historyId: bumpHistory(this.store),
|
|
280
|
+
internalDate: (input as Partial<GmailMessage>).internalDate ?? String(now.getTime()),
|
|
281
|
+
payload,
|
|
282
|
+
sizeEstimate: (input as Partial<GmailMessage>).sizeEstimate ?? bodyText.length + JSON.stringify(headers).length,
|
|
283
|
+
raw: (input as GmailSendRequest).raw ?? (input as Partial<GmailMessage>).raw,
|
|
284
|
+
attachments: (input as GmailSendRequest).attachments ?? (input as Partial<GmailMessage>).attachments,
|
|
285
|
+
};
|
|
286
|
+
return message;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private visibleMessages(options: GmailSearchOptions): GmailMessage[] {
|
|
290
|
+
return Array.from(this.store.messages.values())
|
|
291
|
+
.filter((message) => options.includeSpamTrash || (!message.labelIds.includes("TRASH") && !message.labelIds.includes("SPAM")))
|
|
292
|
+
.filter((message) => !options.labelIds?.length || options.labelIds.every((labelId) => message.labelIds.includes(labelId)))
|
|
293
|
+
.filter((message) => matchesQuery(message, options.q))
|
|
294
|
+
.sort((a, b) => Number(b.internalDate) - Number(a.internalDate))
|
|
295
|
+
.slice(0, options.maxResults ?? undefined)
|
|
296
|
+
.map(clone);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private requireMessage(id: string): GmailMessage {
|
|
300
|
+
const message = this.store.messages.get(id);
|
|
301
|
+
if (!message) throw notFound(`Message not found: ${id}`);
|
|
302
|
+
return message;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private currentUser() {
|
|
306
|
+
const user = this.store.users.get(this.store.currentUserId);
|
|
307
|
+
if (!user) throw notFound(`User not found: ${this.store.currentUserId}`);
|
|
308
|
+
return user;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function normalizeLabels(labelIds: string[]): string[] {
|
|
313
|
+
return Array.from(new Set(labelIds));
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function modifyLabels(current: string[], request: GmailModifyRequest): string[] {
|
|
317
|
+
const next = new Set(current);
|
|
318
|
+
for (const labelId of request.removeLabelIds ?? []) next.delete(labelId);
|
|
319
|
+
for (const labelId of request.addLabelIds ?? []) next.add(labelId);
|
|
320
|
+
return Array.from(next);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function groupThreads(messages: GmailMessage[]): GmailThread[] {
|
|
324
|
+
const byThread = new Map<string, GmailMessage[]>();
|
|
325
|
+
for (const message of messages) {
|
|
326
|
+
if (!byThread.has(message.threadId)) byThread.set(message.threadId, []);
|
|
327
|
+
byThread.get(message.threadId)!.push(message);
|
|
328
|
+
}
|
|
329
|
+
return Array.from(byThread.entries()).map(([id, threadMessages]) => {
|
|
330
|
+
const sorted = threadMessages.sort((a, b) => Number(a.internalDate) - Number(b.internalDate));
|
|
331
|
+
const latest = sorted[sorted.length - 1];
|
|
332
|
+
return {
|
|
333
|
+
id,
|
|
334
|
+
historyId: latest.historyId,
|
|
335
|
+
snippet: latest.snippet,
|
|
336
|
+
messages: sorted.map(clone),
|
|
337
|
+
};
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function buildHeaders(input: Partial<GmailMessage> | GmailSendRequest, currentEmail: string, fromCurrentUser?: boolean): { name: string; value: string }[] {
|
|
342
|
+
const existing = (input as Partial<GmailMessage>).payload?.headers;
|
|
343
|
+
if (existing?.length) return existing.map(clone);
|
|
344
|
+
const send = input as GmailSendRequest;
|
|
345
|
+
return [
|
|
346
|
+
{ name: "From", value: fromCurrentUser ? currentEmail : send.to?.[0] ?? "sender@example.com" },
|
|
347
|
+
{ name: "To", value: send.to?.join(", ") ?? currentEmail },
|
|
348
|
+
...(send.cc?.length ? [{ name: "Cc", value: send.cc.join(", ") }] : []),
|
|
349
|
+
{ name: "Subject", value: send.subject ?? "(no subject)" },
|
|
350
|
+
{ name: "Date", value: new Date().toUTCString() },
|
|
351
|
+
];
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function extractBody(input: Partial<GmailMessage> | GmailSendRequest): string {
|
|
355
|
+
const sendBody = (input as GmailSendRequest).body;
|
|
356
|
+
if (sendBody !== undefined) return sendBody;
|
|
357
|
+
const data = (input as Partial<GmailMessage>).payload?.body.data;
|
|
358
|
+
return data ? decodeBase64Url(data) : "";
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function headerValue(headers: { name: string; value: string }[], name: string): string {
|
|
362
|
+
return headers.find((header) => header.name.toLowerCase() === name.toLowerCase())?.value ?? "";
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function makeSnippet(value: string): string {
|
|
366
|
+
return value.replace(/\s+/g, " ").trim().slice(0, 160);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function matchesQuery(message: GmailMessage, query?: string): boolean {
|
|
370
|
+
if (!query) return true;
|
|
371
|
+
const terms = query.match(/(?:[^\s"]+|"[^"]*")+/g) ?? [];
|
|
372
|
+
return terms.every((term) => matchesTerm(message, term.replace(/^"|"$/g, "")));
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function matchesTerm(message: GmailMessage, term: string): boolean {
|
|
376
|
+
const headers = message.payload.headers;
|
|
377
|
+
const lower = term.toLowerCase();
|
|
378
|
+
if (lower.startsWith("from:")) return headerValue(headers, "From").toLowerCase().includes(lower.slice(5));
|
|
379
|
+
if (lower.startsWith("to:")) return headerValue(headers, "To").toLowerCase().includes(lower.slice(3));
|
|
380
|
+
if (lower.startsWith("subject:")) return headerValue(headers, "Subject").toLowerCase().includes(lower.slice(8));
|
|
381
|
+
if (lower.startsWith("label:")) return message.labelIds.some((label) => label.toLowerCase() === lower.slice(6));
|
|
382
|
+
if (lower === "is:unread") return message.labelIds.includes("UNREAD");
|
|
383
|
+
if (lower === "is:read") return !message.labelIds.includes("UNREAD");
|
|
384
|
+
if (lower === "in:inbox") return message.labelIds.includes("INBOX");
|
|
385
|
+
if (lower === "in:sent") return message.labelIds.includes("SENT");
|
|
386
|
+
if (lower === "in:trash") return message.labelIds.includes("TRASH");
|
|
387
|
+
const haystack = [message.snippet, decodeBase64Url(message.payload.body.data ?? ""), ...headers.map((header) => header.value)].join(" ").toLowerCase();
|
|
388
|
+
return haystack.includes(lower);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export function encodeBase64Url(value: string): string {
|
|
392
|
+
const bytes = new TextEncoder().encode(value);
|
|
393
|
+
let binary = "";
|
|
394
|
+
for (const byte of bytes) {
|
|
395
|
+
binary += String.fromCharCode(byte);
|
|
396
|
+
}
|
|
397
|
+
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export function decodeBase64Url(value: string): string {
|
|
401
|
+
const padded = value.replaceAll("-", "+").replaceAll("_", "/").padEnd(Math.ceil(value.length / 4) * 4, "=");
|
|
402
|
+
const binary = atob(padded);
|
|
403
|
+
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
|
404
|
+
return new TextDecoder().decode(bytes);
|
|
405
|
+
}
|