cf-imap
Version:
IMAP (v4) client for the Cloudflare Workers platform.
434 lines (433 loc) • 18.8 kB
JavaScript
import { connect } from "cloudflare:sockets";
import { decodeMimeEncodedWords } from "./utils/decodeMime";
// TODO: Add documentation when version is 1.x.x
export class CFImap {
options = {
host: "",
port: NaN,
tls: false,
auth: {
username: "",
password: ""
}
};
constructor({ host, port, tls, auth }) {
this.options = {
host,
port,
tls,
auth
};
}
/**
* Raw socket used to communicate with the IMAP server. Null if connect function not run yet.
*/
socket = null;
// TODO: docs (not that necessary, but nice to have)
// ? console log a warning on each function if protocol not imapv4?
session = {};
/**
* Only used to determine if a folder is selected
*/
selectedFolder = "";
encoder = new TextEncoder();
decoder = new TextDecoder();
writer = null;
reader = null;
/**
* Connects to the IMAP server. Must be run after initialising the CFImap class, otherwise nothing will work.
*
* @async
* @returns {void}
*/
connect = async () => {
let options = {
allowHalfOpen: true
};
if (this.options.tls)
options.secureTransport = "starttls";
this.socket = await connect({ hostname: this.options.host, port: this.options.port }, options);
if (this.options.tls) {
const secureSocket = this.socket.startTls();
this.socket = secureSocket;
}
this.writer = this.socket.writable.getWriter();
this.reader = this.socket.readable.getReader();
let chunks = [];
await this.decoder.decode((await this.reader.read()).value);
const encoded = this.encoder.encode(`A1 login ${this.options.auth.username} ${this.options.auth.password}\r\n`);
await this.writer.write(encoded);
let returnvalue = await this.decoder.decode((await this.reader.read()).value);
let responses = await returnvalue.split("\r\n");
// ? Have to test with slow IMAP servers, the speed at which this grabs the read data might be too fast for some.
if (!responses[responses.length - 2].startsWith("A1 OK"))
throw new Error("IMAP server not responding with an A1 OK.", { cause: responses });
if (responses.find(r => r.startsWith("* CAPABILITY")) != undefined) {
let regex = /^\* CAPABILITY (\w{1,}) .{2,}/.exec(responses[0]);
if (regex) {
this.session = {
protocol: regex[1]
};
}
}
else {
let regex = /^A1 OK \[CAPABILITY (\w{1,}) .{2,}(?=])\] User logged in SESSIONID=<(.{1,}(?=>))>/.exec(responses[0]);
if (regex) {
this.session = {
id: regex[2],
protocol: regex[1]
};
}
}
return;
};
/**
* Returns the prefix and hierarchy delimiter to personal and shared namespaces that the logged in user has access to. Should be the second ran function.
*/
getNamespaces = async () => {
if (!this.socket || !this.reader || !this.writer)
throw new Error("Not connected to an IMAP server");
let encoded = await this.encoder.encode("n namespace\r\n");
await this.writer.write(encoded);
let decoded = await this.decoder.decode((await this.reader.read()).value);
let regex = new RegExp(/\* NAMESPACE \(\((.{1,})\)\) (.{1,}) (.{1,})/);
let splitDecoded = decoded.split("\r\n");
if (splitDecoded.length === 0)
throw new Error("Namespaces empty", { cause: splitDecoded });
let regexExec = regex.exec(splitDecoded[0]);
if (!regexExec)
throw new Error("Namespaces - regex issue. If you believe this to be a bug, please report it.", { cause: { response: splitDecoded[0], regex } });
let namespaces = [];
namespaces.push(regexExec[1].split(" ")[0].replaceAll(`"`, ""));
return namespaces;
};
/**
* Returns all folders in the specified namespace along with any flags.
* @param {string} namespace - From which namespace to list folders
* @param {string} filter - String filter
*/
getFolders = async (namespace, filter = "*") => {
if (!this.socket || !this.reader || !this.writer)
throw new Error("Not connected to an IMAP server");
let encoded = await this.encoder.encode(`A1 list "${namespace}" "${filter}"\r\n`);
await this.writer.write(encoded);
let decoded = await this.decoder.decode((await this.reader.read()).value);
let responses = decoded.split("\r\n");
let folders = [];
let regex = new RegExp(/\* LIST \((?<attributes>.{1,})\) (?<delimiter>.{1,}) (?<name>.{1,})/);
for (const response of responses) {
let exec = regex.exec(response);
if (!exec?.groups)
continue;
let attributes = exec?.groups.attributes.split("\\").filter(attr => attr !== "");
for (let i in attributes) {
attributes[i] = attributes[i].trim();
}
folders.push({
name: exec?.groups.name.trim(),
delimiter: exec?.groups.delimiter.trim(),
attributes: attributes
});
}
return folders;
};
/**
* Selects a folder for use in the email GET & FETCH functions. Must be run before those commands, otherwise those commands will throw an error.
* @param folder - Selectable folder
*/
selectFolder = async (folder) => {
if (!this.socket || !this.reader || !this.writer)
throw new Error("Not initialised");
if (!folder)
throw new Error("Folder not given");
await this.writer.write(await this.encoder.encode(`g21 SELECT "${folder}"\r\n`));
let decoded = await this.decoder.decode((await this.reader.read()).value);
let responses = decoded.split("\r\n");
let metadata = {};
for (let response of responses) {
if (response.startsWith("*"))
response = response.replace("* ", "");
if (response.endsWith("EXISTS"))
metadata.emails = parseInt(response.split(" ")[0]);
if (response.endsWith("RECENT"))
metadata.recent = parseInt(response.split(" ")[0]);
if (response.startsWith("FLAGS")) {
let regex = new RegExp(/FLAGS \((?<flags>.{1,})\)/);
let exec = regex.exec(response);
if (!exec)
continue;
if (!exec.groups)
continue;
let flags = [];
for (let flag of exec.groups.flags.split(" ")) {
if (!flag.startsWith("\\"))
continue;
flags.push(flag.replace("\\", ""));
}
metadata.flags = flags;
}
if (response.startsWith("OK")) {
let regex = new RegExp(/OK \[(?<kv>.{1,})\] (?<status>.{1,})/);
let exec = regex.exec(response);
if (!exec)
continue;
if (!exec.groups)
continue;
let { kv, status } = exec.groups;
if (status != "Ok")
continue;
if (kv.startsWith("PERMANENTFLAGS")) {
let flags = [];
let flagExec = new RegExp(/PERMANENTFLAGS \((?<flags>.{1,})\)/).exec(kv);
if (!flagExec)
continue;
if (!flagExec.groups)
continue;
for (let flag of flagExec.groups.flags.split(" ")) {
if (!flag.startsWith("\\"))
continue;
flags.push(flag.replace("\\", ""));
}
metadata.permanentFlags = flags;
continue;
}
let split = kv.split(" ");
try {
let parsed = parseInt(split[1]);
if (isNaN(parsed))
metadata[split[0].toLowerCase()] = split[1];
metadata[split[0].toLowerCase()] = parsed;
}
catch (e) {
throw new Error("Test");
}
}
}
this.selectedFolder = folder;
return metadata;
};
/**
* Fetches emails from a folder specified by the selectFolder() function.
*
* @async
* @param {Object} props - Props
* @param {number} [props.byteLimit] - Maximum size of the emails to fetch (optional, not recommended)
* @param {[ number, number ]} props.limit - Range of emails to fetch.
* @param {boolean} [props.peek=true] - If true (optional, defaults to true), upon fetch the emails won't get the \Seen flag set.
*/
fetchEmails = async ({ folder, byteLimit, limit, peek = true, fetchBody }) => {
if (!this.socket || !this.reader || !this.writer)
throw new Error("Not initialised");
if (!this.selectedFolder)
throw new Error("Folder not selected! Before running this function, run the selectFolder() function!");
let query = [
"A5 FETCH " + limit.join(":") + " ",
"(",
fetchBody && `BODY${peek ? ".PEEK" : ""}[TEXT] `,
"BODY" + (peek && ".PEEK"),
"[",
"HEADER.FIELDS ",
"(",
"SUBJECT ",
"FROM ",
"TO ",
"MESSAGE-ID ",
"CONTENT-TYPE ",
"DATE",
")",
"]",
byteLimit && `<${byteLimit}>`,
")",
"\r\n"
].filter(Boolean); // ? why is this here
let encoded = await this.encoder.encode(query.join(""));
await this.writer.write(encoded);
let decoded = await this.decoder.decode((await this.reader.read()).value);
let responses = decoded.split("\r\n");
if (responses[0].startsWith("A5 BAD"))
throw new Error("IMAP server returns A5 BAD in fetchEmails function", { cause: { response: responses, query: query.join("") } });
// With large data the server might still be streaming data when we grab it from the TCP stream, This basically ensures that we get to the very end.
const timeout = async () => {
// ! Might fail when the response is a failure, might need error checking for that
// @ts-ignore findLastIndex exists on string[], however the tsc compiler thinks it doesn't
if (responses.findLastIndex(r => r.startsWith("A5 OK")) == -1) {
if (!this.reader)
return false; // mostly so it doesnt scream about this.reader being possibly undefined
decoded = await this.decoder.decode((await this.reader.read()).value);
responses = [...responses, ...decoded.split("\r\n")];
return timeout();
}
return true;
};
await timeout();
let emails = [];
let emailsRaw = [];
let currentEmail = [];
// Seperates the emails into seperate arrays
for (let line of responses) {
// "*" is sent by the server as a sort of "start section" kind of thing
if (line.startsWith('*')) {
if (currentEmail.length > 0) {
emailsRaw.push(currentEmail);
currentEmail = [];
}
}
currentEmail.push(line);
}
if (currentEmail.length > 0) {
emailsRaw.push(currentEmail);
}
for (let emailRaw of emailsRaw) {
// ? Looks a bit ugly, might need to be improved (func that finds?)
// ? headers field
let email = {
// --- Can be in different encodings ---
from: decodeMimeEncodedWords(emailRaw.find(r => r.toLowerCase().startsWith("from:"))?.slice("from: ".length).trim() || ""),
to: decodeMimeEncodedWords(emailRaw.find(r => r.toLowerCase().startsWith("to:"))?.slice("to: ".length).trim() || ""),
subject: decodeMimeEncodedWords(emailRaw.find(r => r.toLowerCase().startsWith("subject:"))?.slice("subject: ".length).trim() || ""),
// -------------------------------------
messageID: emailRaw.find(r => r.toLowerCase().startsWith("message-id:"))?.slice("message-id: ".length).trim(),
contentType: emailRaw.find(r => r.toLowerCase().startsWith("content-type:"))?.slice("content-type: ".length).trim(),
date: new Date(emailRaw.find(r => r.toLowerCase().startsWith("date:"))?.slice("date: ".length).trim()),
raw: emailRaw.join("\n"),
body: ""
};
let mutRaw = emailRaw;
// Removes the useless junk at the end of the response. Goes backwards (starts at the last element and works its way up)
for (let i = mutRaw.length; i--; i < 0) {
let el = mutRaw[i];
if (el === "") {
mutRaw.pop();
continue;
}
if (el.startsWith("A5")) {
mutRaw.pop();
continue;
}
if (el === ")") {
mutRaw.pop();
continue;
}
break;
}
if (!fetchBody) {
emails.push(email);
continue;
}
// ! Fails when it has a tab at the beginning (happens with inbox.lv ads)
let bodyStartIndex = mutRaw.findIndex(r => r.trim().startsWith("BODY[TEXT]"));
mutRaw.splice(0, bodyStartIndex + 1);
email.body = mutRaw.join("\n");
emails.push(email);
}
return emails;
};
/**
* Searches emails based on the props given.
*/
searchEmails = async (props) => {
if (!this.socket || !this.reader || !this.writer)
throw new Error("Not initialised");
if (!props)
throw new Error("Props not given");
if (Object.keys(props).length === 0)
throw new Error("No search options given. You must specify at least one search option.");
if (!this.selectedFolder)
throw new Error("Folder not selected! Before running this function, run the selectFolder() function!");
let unFlags = [
"answered",
"deleted",
"draft",
"flagged",
"seen"
];
let command = `A5 SEARCH`;
let options = [];
let keys = Object.keys(props);
for (let key of keys) {
// ! fix later!!!
// @ts-ignore
let value = props[key];
switch (typeof value) {
case "boolean":
if (value)
options.push(key.toUpperCase());
else {
if (unFlags.includes(key))
options.push(`un${key}`.toUpperCase());
}
break;
case "string":
options.push(`${key.toUpperCase()} ${value}`);
break;
case "number":
options.push(`${key.replace("Than", "")} ${value.toString()}`.toUpperCase());
break;
case "object":
if (Array.isArray(value)) {
let values = [];
for (let v of value) {
values.push(`${v}`);
}
options.push(`${key.toUpperCase()} ${values.join("")}`);
}
else if (value instanceof Date && !isNaN(value.valueOf())) {
const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const monthIndex = value.getMonth();
options.push(`${key.toUpperCase()} ${value.getDate()}-${monthNames[monthIndex]}-${value.getFullYear()}`);
}
else if (key == "header") {
options.push(`${key.toUpperCase()} ${value.key.toUpperCase()} "${value.value}"`);
}
break;
}
if (keys.includes("all") && props["all"] === true) {
options = ["ALL"];
}
}
let query = `${command} ${options.join(" ")}`;
await this.writer.write(await this.encoder.encode(query.trim() + "\r\n"));
let decoded = await this.decoder.decode((await this.reader.read()).value);
let responses = decoded.split("\r\n");
let ids = [];
for (let response of responses) {
if (!response.startsWith("* SEARCH"))
continue;
let temp = response.replace("* SEARCH", "").trim().split(" ");
for (let t of temp) {
if (t === "")
continue;
ids.push(parseInt(t));
}
break;
}
return ids;
};
/**
* Requests a "checkpoint" on the server, a.k.a requests that the server does some houskeeping.
* Almost never used, but exists in the RFC 3501 spec.
* Removed in the RFC 9051 spec, however most providers still support it.
*/
check = async () => {
if (!this.socket || !this.reader || !this.writer)
throw new Error("Not initialised");
let query = `FXXZ CHECK\r\n`;
let encoded = await this.encoder.encode(query);
await this.writer.write(encoded);
let decoded = await this.decoder.decode((await this.reader.read()).value);
let responses = decoded.split("\r\n");
return responses;
};
/**
* Logs the user out of the IMAP session and closes the socket.
*/
logout = async () => {
if (!this.socket || !this.reader || !this.writer)
throw new Error("Not initialised");
let query = `A023 LOGOUT\r\n`;
let encoded = await this.encoder.encode(query);
await this.writer.write(encoded);
await this.socket.close(); // The server should close it automatically, so this is more of a failsafe.
return true;
};
}