pi-protonmail 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/CHANGELOG.md +7 -0
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/biome.json +25 -0
- package/package.json +63 -0
- package/src/constants.ts +1 -0
- package/src/hub.ts +559 -0
- package/src/index.ts +7 -0
- package/src/proton-bridge.ts +444 -0
- package/src/protonmail.ts +624 -0
- package/src/secret-refs.ts +81 -0
- package/src/types.ts +90 -0
- package/src/workspace.ts +96 -0
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
import { access, copyFile, mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { createConnection } from "node:net";
|
|
3
|
+
import { join, relative } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { ImapFlow } from "imapflow";
|
|
6
|
+
import { simpleParser } from "mailparser";
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
BridgeStatusResult,
|
|
10
|
+
MailboxInfo,
|
|
11
|
+
MailboxListResult,
|
|
12
|
+
MessageInfo,
|
|
13
|
+
MessageListResult,
|
|
14
|
+
ProtonBridgeConfig,
|
|
15
|
+
} from "./types.ts";
|
|
16
|
+
|
|
17
|
+
export interface ProtonBridgeImportResult {
|
|
18
|
+
workspace_root: string;
|
|
19
|
+
period_root: string;
|
|
20
|
+
mail_root: string;
|
|
21
|
+
inbox_root: string;
|
|
22
|
+
mailbox: string;
|
|
23
|
+
profile: string;
|
|
24
|
+
message_count: number;
|
|
25
|
+
attachment_count: number;
|
|
26
|
+
messages: Array<{
|
|
27
|
+
uid: string;
|
|
28
|
+
message_id?: string;
|
|
29
|
+
from?: string;
|
|
30
|
+
subject?: string;
|
|
31
|
+
date?: string;
|
|
32
|
+
attachment_count: number;
|
|
33
|
+
raw_path: string;
|
|
34
|
+
saved_at: string;
|
|
35
|
+
attachments: Array<{
|
|
36
|
+
filename: string;
|
|
37
|
+
content_type?: string;
|
|
38
|
+
size?: number;
|
|
39
|
+
mail_path: string;
|
|
40
|
+
inbox_path: string;
|
|
41
|
+
}>;
|
|
42
|
+
}>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface ImportOptions {
|
|
46
|
+
cwd: string;
|
|
47
|
+
workspaceRoot: string;
|
|
48
|
+
period: string;
|
|
49
|
+
mailbox: string;
|
|
50
|
+
profile: string;
|
|
51
|
+
unseenOnly?: boolean;
|
|
52
|
+
query?: string;
|
|
53
|
+
markSeen?: boolean;
|
|
54
|
+
limit?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function sanitizeFilename(value: string): string {
|
|
58
|
+
const text = value.replace(/[\\/:*?"<>|\r\n]+/g, "_").trim();
|
|
59
|
+
return text.replace(/\s+/g, " ") || "unnamed";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function sanitizePathSegment(value: string): string {
|
|
63
|
+
const text = value.replace(/[^A-Za-z0-9._-]+/g, "-").trim();
|
|
64
|
+
return text.replace(/^[-._]+|[-._]+$/g, "") || "item";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function monthRange(period: string): { since: Date; before: Date } {
|
|
68
|
+
if (!/^\d{4}-\d{2}$/.test(period))
|
|
69
|
+
throw new Error(`Invalid period '${period}'. Expected YYYY-MM.`);
|
|
70
|
+
const year = Number.parseInt(period.slice(0, 4), 10);
|
|
71
|
+
const month = Number.parseInt(period.slice(5, 7), 10);
|
|
72
|
+
const since = new Date(Date.UTC(year, month - 1, 1));
|
|
73
|
+
const before =
|
|
74
|
+
month === 12 ? new Date(Date.UTC(year + 1, 0, 1)) : new Date(Date.UTC(year, month, 1));
|
|
75
|
+
return { since, before };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function toStringValue(value: unknown): string {
|
|
79
|
+
if (typeof value === "string") return value;
|
|
80
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
81
|
+
if (value && typeof value === "object" && "toString" in value) return String(value);
|
|
82
|
+
return "";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function probePort(
|
|
86
|
+
host: string,
|
|
87
|
+
port: number,
|
|
88
|
+
timeout = 1500,
|
|
89
|
+
): Promise<{ open: boolean; banner?: string; error?: string }> {
|
|
90
|
+
return new Promise((resolve) => {
|
|
91
|
+
const socket = createConnection({ host, port });
|
|
92
|
+
let banner = "";
|
|
93
|
+
let settled = false;
|
|
94
|
+
|
|
95
|
+
const finish = (result: { open: boolean; banner?: string; error?: string }) => {
|
|
96
|
+
if (settled) return;
|
|
97
|
+
settled = true;
|
|
98
|
+
try {
|
|
99
|
+
socket.destroy();
|
|
100
|
+
} catch {
|
|
101
|
+
// ignore
|
|
102
|
+
}
|
|
103
|
+
resolve(result);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
socket.setTimeout(timeout);
|
|
107
|
+
socket.on("data", (chunk) => {
|
|
108
|
+
banner += chunk.toString("utf8");
|
|
109
|
+
});
|
|
110
|
+
socket.on("connect", () => {
|
|
111
|
+
setTimeout(() => finish({ open: true, banner: banner.trim() || undefined }), 50);
|
|
112
|
+
});
|
|
113
|
+
socket.on("timeout", () => finish({ open: false, error: "timed out" }));
|
|
114
|
+
socket.on("error", (error) =>
|
|
115
|
+
finish({ open: false, error: error instanceof Error ? error.message : String(error) }),
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function normalizeSecurity(security: string): "ssl" | "starttls" | "auto" | "plain" {
|
|
121
|
+
const value = security.trim().toLowerCase();
|
|
122
|
+
if (value === "ssl" || value === "starttls" || value === "plain" || value === "auto")
|
|
123
|
+
return value;
|
|
124
|
+
return "starttls";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function connectImap(config: ProtonBridgeConfig): Promise<ImapFlow> {
|
|
128
|
+
const security = normalizeSecurity(config.security);
|
|
129
|
+
const client = new ImapFlow({
|
|
130
|
+
host: config.host,
|
|
131
|
+
port: config.imapPort,
|
|
132
|
+
secure: security === "ssl",
|
|
133
|
+
doSTARTTLS: security === "starttls" || security === "auto",
|
|
134
|
+
auth: {
|
|
135
|
+
user: config.username ?? "",
|
|
136
|
+
pass: config.password ?? "",
|
|
137
|
+
},
|
|
138
|
+
tls: {
|
|
139
|
+
rejectUnauthorized: false,
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
await client.connect();
|
|
143
|
+
return client;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function listMailboxes(client: ImapFlow): Promise<MailboxInfo[]> {
|
|
147
|
+
const mailboxes: MailboxInfo[] = [];
|
|
148
|
+
const listing = await client.list();
|
|
149
|
+
for await (const row of listing as AsyncIterable<Record<string, unknown>>) {
|
|
150
|
+
const name = toStringValue(row.path ?? row.name ?? row.mailbox ?? row.id ?? row.raw).trim();
|
|
151
|
+
const raw = toStringValue(row.raw).trim() || undefined;
|
|
152
|
+
const flags = Array.isArray(row.flags)
|
|
153
|
+
? row.flags.map((flag) => toStringValue(flag)).filter(Boolean)
|
|
154
|
+
: undefined;
|
|
155
|
+
const delimiter = row.delimiter == null ? null : toStringValue(row.delimiter);
|
|
156
|
+
mailboxes.push({
|
|
157
|
+
name: name || raw || "unnamed",
|
|
158
|
+
raw,
|
|
159
|
+
flags,
|
|
160
|
+
delimiter,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
mailboxes.sort((left, right) => left.name.localeCompare(right.name));
|
|
164
|
+
return mailboxes;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function monthSearch(period?: string): { since?: Date; before?: Date } {
|
|
168
|
+
if (!period) return {};
|
|
169
|
+
const { since, before } = monthRange(period);
|
|
170
|
+
return { since, before };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function searchUids(
|
|
174
|
+
client: ImapFlow,
|
|
175
|
+
mailbox: string,
|
|
176
|
+
period?: string,
|
|
177
|
+
unseenOnly = false,
|
|
178
|
+
readOnly = true,
|
|
179
|
+
): Promise<string[]> {
|
|
180
|
+
await client.mailboxOpen(mailbox, { readOnly });
|
|
181
|
+
const search: Record<string, unknown> = { uid: true };
|
|
182
|
+
if (unseenOnly) search.seen = false;
|
|
183
|
+
else search.all = true;
|
|
184
|
+
Object.assign(search, monthSearch(period));
|
|
185
|
+
const result = await client.search(search as Record<string, unknown>, { uid: true });
|
|
186
|
+
const uids = Array.isArray(result) ? result : [];
|
|
187
|
+
return uids.map((uid) => String(uid));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function messageFromParsed(
|
|
191
|
+
uid: string,
|
|
192
|
+
parsed: Awaited<ReturnType<typeof simpleParser>>,
|
|
193
|
+
rawSize?: number,
|
|
194
|
+
): MessageInfo {
|
|
195
|
+
const from = parsed.from?.text?.trim() || undefined;
|
|
196
|
+
const subject = parsed.subject?.trim() || undefined;
|
|
197
|
+
const date = parsed.date instanceof Date ? parsed.date.toISOString() : undefined;
|
|
198
|
+
const attachments = parsed.attachments.map((attachment) => ({
|
|
199
|
+
filename: attachment.filename || "attachment",
|
|
200
|
+
content_type: attachment.contentType || undefined,
|
|
201
|
+
size: attachment.size || attachment.content?.length || undefined,
|
|
202
|
+
}));
|
|
203
|
+
return {
|
|
204
|
+
uid,
|
|
205
|
+
message_id: parsed.messageId?.trim() || undefined,
|
|
206
|
+
from,
|
|
207
|
+
subject,
|
|
208
|
+
date,
|
|
209
|
+
attachments,
|
|
210
|
+
attachment_count: attachments.length,
|
|
211
|
+
raw_size: rawSize,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function matchesQuery(summary: MessageInfo, query?: string): boolean {
|
|
216
|
+
if (!query?.trim()) return true;
|
|
217
|
+
const needle = query.trim().toLowerCase();
|
|
218
|
+
const values = [
|
|
219
|
+
summary.subject,
|
|
220
|
+
summary.from,
|
|
221
|
+
summary.message_id,
|
|
222
|
+
...summary.attachments.map((attachment) => attachment.filename),
|
|
223
|
+
];
|
|
224
|
+
return values.some((value) => value?.toLowerCase().includes(needle));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function fetchParsedMessage(
|
|
228
|
+
client: ImapFlow,
|
|
229
|
+
uid: string,
|
|
230
|
+
): Promise<{ parsed: Awaited<ReturnType<typeof simpleParser>>; source: Buffer }> {
|
|
231
|
+
const message = (await client.fetchOne(uid, { uid: true, source: true })) as Record<
|
|
232
|
+
string,
|
|
233
|
+
unknown
|
|
234
|
+
>;
|
|
235
|
+
const source =
|
|
236
|
+
message.source instanceof Buffer
|
|
237
|
+
? message.source
|
|
238
|
+
: Buffer.from(message.source as Uint8Array | string);
|
|
239
|
+
const parsed = await simpleParser(source);
|
|
240
|
+
return { parsed, source };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function ensureDir(path: string): Promise<void> {
|
|
244
|
+
await mkdir(path, { recursive: true });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export async function protonBridgeStatus(config: ProtonBridgeConfig): Promise<BridgeStatusResult> {
|
|
248
|
+
const imapProbe = await probePort(config.host, config.imapPort);
|
|
249
|
+
const smtpProbe = await probePort(config.host, config.smtpPort);
|
|
250
|
+
const result: BridgeStatusResult = {
|
|
251
|
+
config: {
|
|
252
|
+
host: config.host,
|
|
253
|
+
imap_port: config.imapPort,
|
|
254
|
+
smtp_port: config.smtpPort,
|
|
255
|
+
security: config.security,
|
|
256
|
+
default_mailbox: config.defaultMailbox,
|
|
257
|
+
username_set: Boolean(config.username),
|
|
258
|
+
password_set: Boolean(config.password),
|
|
259
|
+
},
|
|
260
|
+
imap: imapProbe,
|
|
261
|
+
smtp: smtpProbe,
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
if (config.username && config.password && imapProbe.open) {
|
|
265
|
+
let client: ImapFlow | undefined;
|
|
266
|
+
try {
|
|
267
|
+
client = await connectImap(config);
|
|
268
|
+
const mailboxes = await listMailboxes(client);
|
|
269
|
+
result.login = {
|
|
270
|
+
ok: true,
|
|
271
|
+
mailbox_count: mailboxes.length,
|
|
272
|
+
mailboxes: mailboxes.slice(0, 10).map((mailbox) => ({ name: mailbox.name })),
|
|
273
|
+
};
|
|
274
|
+
} catch (error) {
|
|
275
|
+
result.login = { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
276
|
+
} finally {
|
|
277
|
+
await client?.logout().catch(() => undefined);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return result;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export async function protonBridgeListMailboxes(
|
|
285
|
+
config: ProtonBridgeConfig,
|
|
286
|
+
query?: string,
|
|
287
|
+
): Promise<MailboxListResult> {
|
|
288
|
+
if (!config.username || !config.password)
|
|
289
|
+
throw new Error("Missing Proton Bridge username/password.");
|
|
290
|
+
let client: ImapFlow | undefined;
|
|
291
|
+
try {
|
|
292
|
+
client = await connectImap(config);
|
|
293
|
+
const rows = await listMailboxes(client);
|
|
294
|
+
const filtered = query?.trim()
|
|
295
|
+
? rows.filter((row) => {
|
|
296
|
+
const needle = query.trim().toLowerCase();
|
|
297
|
+
return [row.name, row.raw ?? ""].some((value) => value.toLowerCase().includes(needle));
|
|
298
|
+
})
|
|
299
|
+
: rows;
|
|
300
|
+
return { mailboxes: filtered, count: filtered.length };
|
|
301
|
+
} finally {
|
|
302
|
+
await client?.logout().catch(() => undefined);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export async function protonBridgeListMessages(
|
|
307
|
+
config: ProtonBridgeConfig,
|
|
308
|
+
mailbox: string | undefined,
|
|
309
|
+
period?: string,
|
|
310
|
+
query?: string,
|
|
311
|
+
unseenOnly = false,
|
|
312
|
+
limit = 20,
|
|
313
|
+
): Promise<MessageListResult> {
|
|
314
|
+
if (!config.username || !config.password)
|
|
315
|
+
throw new Error("Missing Proton Bridge username/password.");
|
|
316
|
+
const selectedMailbox = mailbox || config.defaultMailbox;
|
|
317
|
+
if (!selectedMailbox)
|
|
318
|
+
throw new Error("No mailbox provided and PROTON_BRIDGE_DEFAULT_MAILBOX is not set.");
|
|
319
|
+
let client: ImapFlow | undefined;
|
|
320
|
+
try {
|
|
321
|
+
client = await connectImap(config);
|
|
322
|
+
const uids = await searchUids(client, selectedMailbox, period, unseenOnly);
|
|
323
|
+
const messages: MessageInfo[] = [];
|
|
324
|
+
for (const uid of [...uids].reverse()) {
|
|
325
|
+
const { parsed, source } = await fetchParsedMessage(client, uid);
|
|
326
|
+
const summary = messageFromParsed(uid, parsed, source.length);
|
|
327
|
+
if (!summary.attachment_count) continue;
|
|
328
|
+
if (!matchesQuery(summary, query)) continue;
|
|
329
|
+
messages.push(summary);
|
|
330
|
+
if (messages.length >= limit) break;
|
|
331
|
+
}
|
|
332
|
+
return { mailbox: selectedMailbox, count: messages.length, messages };
|
|
333
|
+
} finally {
|
|
334
|
+
await client?.logout().catch(() => undefined);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export async function protonBridgeImportAttachments(
|
|
339
|
+
config: ProtonBridgeConfig,
|
|
340
|
+
options: ImportOptions,
|
|
341
|
+
): Promise<ProtonBridgeImportResult> {
|
|
342
|
+
if (!config.username || !config.password)
|
|
343
|
+
throw new Error("Missing Proton Bridge username/password.");
|
|
344
|
+
const selectedMailbox = options.mailbox || config.defaultMailbox;
|
|
345
|
+
if (!selectedMailbox)
|
|
346
|
+
throw new Error("No mailbox provided and PROTON_BRIDGE_DEFAULT_MAILBOX is not set.");
|
|
347
|
+
if (!/^\d{4}-\d{2}$/.test(options.period))
|
|
348
|
+
throw new Error(`Invalid period '${options.period}'. Expected YYYY-MM.`);
|
|
349
|
+
|
|
350
|
+
const workspaceRoot = options.workspaceRoot.trim();
|
|
351
|
+
const baseRoot = join(options.cwd, workspaceRoot);
|
|
352
|
+
const periodRoot = join(baseRoot, options.period);
|
|
353
|
+
const mailRoot = join(periodRoot, "_mail", sanitizePathSegment(selectedMailbox));
|
|
354
|
+
const inboxRoot = join(periodRoot, "_inbox");
|
|
355
|
+
await ensureDir(mailRoot);
|
|
356
|
+
await ensureDir(inboxRoot);
|
|
357
|
+
|
|
358
|
+
let client: ImapFlow | undefined;
|
|
359
|
+
const importedMessages: ProtonBridgeImportResult["messages"] = [];
|
|
360
|
+
let importedAttachmentCount = 0;
|
|
361
|
+
try {
|
|
362
|
+
client = await connectImap(config);
|
|
363
|
+
const uids = await searchUids(
|
|
364
|
+
client,
|
|
365
|
+
selectedMailbox,
|
|
366
|
+
options.period,
|
|
367
|
+
options.unseenOnly,
|
|
368
|
+
!options.markSeen,
|
|
369
|
+
);
|
|
370
|
+
const selected = [...uids].reverse().slice(0, Math.max(options.limit ?? 100, 1) * 10);
|
|
371
|
+
|
|
372
|
+
for (const uid of selected) {
|
|
373
|
+
const { parsed, source } = await fetchParsedMessage(client, uid);
|
|
374
|
+
const summary = messageFromParsed(uid, parsed, source.length);
|
|
375
|
+
if (summary.attachment_count === 0) continue;
|
|
376
|
+
if (!matchesQuery(summary, options.query)) continue;
|
|
377
|
+
|
|
378
|
+
const messageDir = join(mailRoot, `uid-${uid}`);
|
|
379
|
+
await ensureDir(messageDir);
|
|
380
|
+
const rawPath = join(messageDir, "raw.eml");
|
|
381
|
+
await writeFile(rawPath, source);
|
|
382
|
+
|
|
383
|
+
const attachments: ProtonBridgeImportResult["messages"][number]["attachments"] = [];
|
|
384
|
+
for (const [index, attachment] of parsed.attachments.entries()) {
|
|
385
|
+
const filename = sanitizeFilename(attachment.filename || `attachment-${index + 1}`);
|
|
386
|
+
const savedName = `${String(index + 1).padStart(2, "0")}__${filename}`;
|
|
387
|
+
const attachmentPath = join(messageDir, savedName);
|
|
388
|
+
await writeFile(attachmentPath, attachment.content);
|
|
389
|
+
|
|
390
|
+
const inboxName = `uid-${uid}__${savedName}`;
|
|
391
|
+
const inboxPath = join(inboxRoot, inboxName);
|
|
392
|
+
try {
|
|
393
|
+
await access(inboxPath);
|
|
394
|
+
} catch {
|
|
395
|
+
await copyFile(attachmentPath, inboxPath);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
attachments.push({
|
|
399
|
+
filename,
|
|
400
|
+
content_type: attachment.contentType || undefined,
|
|
401
|
+
size: attachment.size || attachment.content?.length || undefined,
|
|
402
|
+
mail_path: relative(options.cwd, attachmentPath),
|
|
403
|
+
inbox_path: relative(options.cwd, inboxPath),
|
|
404
|
+
});
|
|
405
|
+
importedAttachmentCount += 1;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const meta = {
|
|
409
|
+
workspace_root: workspaceRoot,
|
|
410
|
+
period: options.period,
|
|
411
|
+
mailbox: selectedMailbox,
|
|
412
|
+
uid,
|
|
413
|
+
message_id: parsed.messageId?.trim() || undefined,
|
|
414
|
+
from: summary.from,
|
|
415
|
+
subject: summary.subject,
|
|
416
|
+
date: summary.date,
|
|
417
|
+
attachment_count: attachments.length,
|
|
418
|
+
attachments,
|
|
419
|
+
raw_path: relative(options.cwd, rawPath),
|
|
420
|
+
saved_at: new Date().toISOString(),
|
|
421
|
+
};
|
|
422
|
+
await writeFile(join(messageDir, "meta.json"), `${JSON.stringify(meta, null, 2)}\n`);
|
|
423
|
+
importedMessages.push(meta);
|
|
424
|
+
|
|
425
|
+
if (options.markSeen) {
|
|
426
|
+
await client.messageFlagsAdd(uid, ["\\Seen"], { uid: true });
|
|
427
|
+
}
|
|
428
|
+
if (importedMessages.length >= (options.limit ?? 100)) break;
|
|
429
|
+
}
|
|
430
|
+
return {
|
|
431
|
+
workspace_root: relative(options.cwd, baseRoot),
|
|
432
|
+
period_root: relative(options.cwd, periodRoot),
|
|
433
|
+
mail_root: relative(options.cwd, mailRoot),
|
|
434
|
+
inbox_root: relative(options.cwd, inboxRoot),
|
|
435
|
+
mailbox: selectedMailbox,
|
|
436
|
+
profile: sanitizePathSegment(options.profile),
|
|
437
|
+
message_count: importedMessages.length,
|
|
438
|
+
attachment_count: importedAttachmentCount,
|
|
439
|
+
messages: importedMessages,
|
|
440
|
+
};
|
|
441
|
+
} finally {
|
|
442
|
+
await client?.logout().catch(() => undefined);
|
|
443
|
+
}
|
|
444
|
+
}
|