1 | |
2 | import { assert } from "https://deno.land/[email protected]/testing/asserts.ts"; |
3 |
|
4 | interface EmailAddress { |
5 | email: string; |
6 | } |
7 |
|
8 | interface EmailDetails { |
9 | from: string; |
10 | to: string; |
11 | cc: string; |
12 | subject: string; |
13 | messageBody: string; |
14 | mailboxId: string; |
15 | } |
16 |
|
17 | |
18 | * TinyJMAPClient class provides methods to interact with a JMAP server. |
19 | */ |
20 | export class TinyJMAPClient { |
21 | private hostname: string; |
22 | private username: string; |
23 | private token: string; |
24 | private session: any; |
25 | private api_url: string | null = null; |
26 | private account_id: string | null = null; |
27 | private identity_id: string | null = null; |
28 |
|
29 | |
30 | * Constructor initializes the client with basic settings. |
31 | * |
32 | * @param hostname - JMAP server hostname. |
33 | * @param username - Username for authentication. |
34 | * @param token - Authentication token. |
35 | */ |
36 | constructor(hostname: string, username: string, token: string) { |
37 | assert(hostname.length > 0); |
38 | assert(username.length > 0); |
39 | assert(token.length > 0); |
40 |
|
41 | this.hostname = hostname; |
42 | this.username = username; |
43 | this.token = token; |
44 | this.session = null; |
45 | } |
46 |
|
47 | |
48 | * Retrieve the JMAP session. |
49 | * |
50 | * @returns A promise that resolves with the session object. |
51 | */ |
52 | async get_session() { |
53 | if (this.session) { |
54 | return this.session; |
55 | } |
56 |
|
57 | const r = await fetch(`https://${this.hostname}/.well-known/jmap`, { |
58 | method: "GET", |
59 | headers: { |
60 | "Content-Type": "application/json", |
61 | "Authorization": `Bearer ${this.token}`, |
62 | }, |
63 | }); |
64 |
|
65 | if (!r.ok) { |
66 | throw new Error(`Failed to fetch JMAP session: ${r.status} ${r.statusText}`); |
67 | } |
68 |
|
69 | this.session = await r.json(); |
70 | this.api_url = this.session.apiUrl; |
71 | return this.session; |
72 | } |
73 |
|
74 | |
75 | * Fetch the account ID for JMAP operations. |
76 | * |
77 | * @returns A promise that resolves with the account ID. |
78 | */ |
79 | async get_account_id() { |
80 | if (this.account_id) { |
81 | return this.account_id; |
82 | } |
83 |
|
84 | const session = await this.get_session(); |
85 | const account_id = session.primaryAccounts["urn:ietf:params:jmap:mail"]; |
86 | this.account_id = account_id; |
87 | return account_id; |
88 | } |
89 |
|
90 | |
91 | * Retrieve the identity ID. |
92 | * |
93 | * @returns A promise that resolves with the identity ID. |
94 | */ |
95 | async get_identity_id() { |
96 | if (this.identity_id) { |
97 | return this.identity_id; |
98 | } |
99 |
|
100 | const identity_res = await this.make_jmap_call({ |
101 | using: [ |
102 | "urn:ietf:params:jmap:core", |
103 | "urn:ietf:params:jmap:submission", |
104 | ], |
105 | methodCalls: [ |
106 | ["Identity/get", { accountId: await this.get_account_id() }, "i"], |
107 | ], |
108 | }); |
109 |
|
110 | const identity_id = identity_res.methodResponses[0][1]["list"].find( |
111 | (i: any) => i.email === this.username |
112 | ).id; |
113 |
|
114 | this.identity_id = identity_id; |
115 | return this.identity_id; |
116 | } |
117 | |
118 | |
119 | * Perform a JMAP API call. |
120 | * |
121 | * @param call - The API call details. |
122 | * @returns A promise that resolves with the API call result. |
123 | */ |
124 | async make_jmap_call(call: any) { |
125 | const res = await fetch(this.api_url as string, { |
126 | method: "POST", |
127 | headers: { |
128 | "Content-Type": "application/json", |
129 | "Authorization": `Bearer ${this.token}`, |
130 | }, |
131 | body: call, |
132 | }); |
133 |
|
134 | if (!res.ok) { |
135 | throw new Error(`Failed to make JMAP API call: ${res.status} ${res.statusText}`); |
136 | } |
137 |
|
138 | return await res.json(); |
139 | } |
140 |
|
141 | |
142 | |
143 | * Create a new email draft and optionally send it. |
144 | * |
145 | * @param emailDetails - Object containing details of the email. |
146 | * @param shouldSend - Flag indicating whether to send the email immediately. Default is `false`. |
147 | * @returns A promise that resolves once the operation is complete. |
148 | * |
149 | * @example |
150 | * ```typescript |
151 | * const emailDetails: EmailDetails = { |
152 | * from: "sender@example.com", |
153 | * to: "recipient@example.com", |
154 | * subject: "Test Email", |
155 | * messageBody: "This is a test email body.", |
156 | * mailboxId: "your_mailbox_id_here", |
157 | * }; |
158 | * |
159 | * await client.create_draft_email(emailDetails, true); // Sends the email |
160 | * await client.create_draft_email(emailDetails, false); // Only saves as draft |
161 | * ``` |
162 | */ |
163 | async create_draft_email(emailDetails: EmailDetails, shouldSend: boolean = false): Promise<void> { |
164 | const { from, to, subject, messageBody, mailboxId } = emailDetails; |
165 | const accountId = await this.get_account_id(); |
166 | const identityId = await this.get_identity_id(); |
167 |
|
168 | const draftObject = { |
169 | from: [{ email: from}], |
170 | to: [{ email: to}], |
171 | subject, |
172 | keywords: shouldSend ? {} : { $draft: true }, |
173 | mailboxIds: { [mailboxId]: true }, |
174 | bodyValues: { body: { value: messageBody, charset: "utf-8"}}, |
175 | textBody: [{ partId: "body", type: "text/plain"}] |
176 | }; |
177 |
|
178 |
|
179 | const res = await this.make_jmap_call(JSON.stringify({ |
180 | using: [ |
181 | "urn:ietf:params:jmap:core", |
182 | "urn:ietf:params:jmap:mail", |
183 | "urn:ietf:params:jmap:submission", |
184 | ], |
185 | methodCalls: [ |
186 | ["Email/set", { accountId, create: { draft: draftObject } }, "a"], |
187 | [ |
188 | "EmailSubmission/set", |
189 | { |
190 | accountId, |
191 | onSuccessDestroyEmail: ["#sendIt"], |
192 | create: { sendIt: { emailId: "#draft", identityId } }, |
193 | }, |
194 | "b", |
195 | ], |
196 | ], |
197 | })); |
198 |
|
199 | if (!res.ok) { |
200 | throw new Error(`Failed to send the created Draft using JMAP': ${res.status} ${res.statusText}`) |
201 | } |
202 |
|
203 | return await res.json(); |
204 | } |
205 | } |
206 |
|