Skip to content

Modern API #3

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 10 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 44 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ on:
branches: [main]

jobs:
build:
test:
runs-on: ubuntu-latest

strategy:
Expand All @@ -30,3 +30,46 @@ jobs:
yarn install
- run: yarn run build
- run: yarn test
- name: Coveralls
uses: coverallsapp/github-action@master
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
flag-name: suite-${{ matrix.node-version }}
parallel: true

e2e:
runs-on: ubuntu-latest

strategy:
matrix:
node-version: [14.x, 16.x]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/

steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
- name: Install Deps
run: |
npm install -g yarn
yarn install
- run: yarn run build
- run: yarn test:e2e
- name: Coveralls
uses: coverallsapp/github-action@master
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
flag-name: e2e-${{ matrix.node-version }}
parallel: true

finish:
needs: [test, e2e]
runs-on: ubuntu-latest
steps:
- name: Coveralls Finished
uses: coverallsapp/github-action@master
with:
github-token: ${{ secrets.github_token }}
parallel-finished: true
7 changes: 1 addition & 6 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,7 @@ module.exports = {
coverageProvider: "v8",

// A list of reporter names that Jest uses when writing coverage reports
// coverageReporters: [
// "json",
// "text",
// "lcov",
// "clover"
// ],
coverageReporters: ["lcov"],

// An object that configures minimum threshold enforcement for coverage results
// coverageThreshold: undefined,
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,14 @@
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"dependencies": {
"tiny-typed-emitter": "2.1.0",
"utf7": "git+https://github.com/LoveAndCoding/utf7.git"
},
"scripts": {
"build": "tsc -d",
"test": "jest",
"test:all": "test && test:e2e && test:old",
"test:e2e": "jest --testMatch=\"**/test/e2e/**/*.test.ts\"",
"test:old": "node test/test.js"
},
"engines": {
Expand Down
182 changes: 182 additions & 0 deletions src/commands/base.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { EventEmitter } from "events";

import { IMAPError, NotImplementedError } from "../errors";

import Connection from "../connection";
import { ContinueResponse, TaggedResponse, UntaggedResponse } from "../parser";

// General commands
type GeneralCommandTypes = "CAPABILITY" | "ID" | "IDLE" | "NOOP";

// Login/Auth commands
type LoginOrAuthCommandTypes = "AUTHENTICATE" | "LOGIN" | "LOGOUT" | "STARTTLS";

// Mailbox commands
type MailboxCommandTypes =
| "APPEND"
| "CREATE"
| "DELETE"
| "EXAMINE"
| "LIST"
| "LSUB"
| "RENAME"
| "SELECT"
| "STATUS"
| "SUBSCRIBE"
| "UNSUBSCRIBE";

// Message commands
type MessageCommandTypes =
| "CHECK"
| "CLOSE"
| "COPY"
| "EXPUNGE"
| "FETCH"
| "SEARCH"
| "STORE"
| "UID";

export type CommandType =
| GeneralCommandTypes
| LoginOrAuthCommandTypes
| MailboxCommandTypes
| MessageCommandTypes;

export type StandardResponseTypes =
| ContinueResponse
| TaggedResponse
| UntaggedResponse;

const MAX_TAG_ALPHA_LENGTH = 400;

function* commandIdGenerator(): Generator<string, never> {
const alpha = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("");
let alphaCount = 0;
do {
let lead = "";
let toAddCount = alphaCount;
while (toAddCount >= 0) {
lead += alpha[toAddCount % alpha.length];
toAddCount -= alpha.length;
}

for (let num = 1; num < Number.MAX_SAFE_INTEGER; num++) {
yield `${lead}${num.toString().padStart(5, "0")}`;
}

if (alphaCount >= MAX_TAG_ALPHA_LENGTH * 26) {
// We've sent more commands than is resonable already, but
// start over just in case. Are we approaching heat-death
// of the universe yet?
alphaCount = 0;
}
} while (++alphaCount < Number.MAX_SAFE_INTEGER);
throw new Error("How did you even get here?!?!");
}

const CommandId = commandIdGenerator();

export abstract class Command<T = string> extends EventEmitter {
public readonly id: string;

protected readonly commandPromise: Promise<T>;

constructor(
public readonly type: CommandType,
public readonly requiresOwnContext: boolean = false,
) {
super();
this.id = CommandId.next().value;

this.commandPromise = new Promise<T>(this.executor);
}

protected executor = (
resolve: (result: T) => void,
reject: (reason: any) => any,
): void => {
const cleanUpHandlers = () => {
this.off("results", successHandler);
this.off("error", errorHandler);
this.off("cancel", cancelHandler);
};
const successHandler = (results) => {
try {
resolve(results);
} catch (err) {
reject(err);
}
cleanUpHandlers();
};
const errorHandler = (err: any) => {
reject(err);
cleanUpHandlers();
};
const cancelHandler = () => {
reject("Command canceled");
cleanUpHandlers();
};

this.once("results", successHandler);
this.once("error", errorHandler);
this.once("cancel", cancelHandler);
};

protected getCommand(): string {
return this.type;
}

public run(connection: Connection): Promise<T> {
const cmdText = this.getFullAnnotatedCommand();
let responses: StandardResponseTypes[] = [];
connection.send(cmdText);

const responseHandler = (response: StandardResponseTypes) => {
responses.push(response);
if (response instanceof TaggedResponse) {
if (response.tag.id === this.id) {
if (response.status.status === "OK") {
this.emit("results", this.parseResponse(responses));
} else {
this.emit("error", this.parseNonOKResponse(responses));
}
connection.off("response", responseHandler);
} else {
// If that was data for another command, clear it
responses = [];
}
}
};

connection.on("response", responseHandler);
return this.commandPromise;
}

protected parseNonOKResponse(
responses: StandardResponseTypes[],
): IMAPError {
const taggedResponse = responses[responses.length - 1];

if (taggedResponse && taggedResponse instanceof TaggedResponse) {
let msg = `Got non-OK status "${taggedResponse.status.status}" from command`;
if (taggedResponse.status.text) {
msg += `\r\n${taggedResponse.status.text.content}`;
}
return new IMAPError(msg);
}
}

protected parseResponse(responses: StandardResponseTypes[]): T {
throw new NotImplementedError(
"Response parsing has not be implemented for this command",
);
}

public get results(): Promise<T> {
return this.commandPromise;
}

public getFullAnnotatedCommand() {
return `${this.id} ${this.getCommand()}`;
}
}
35 changes: 35 additions & 0 deletions src/commands/capability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import {
CapabilityList,
CapabilityTextCode,
TaggedResponse,
UntaggedResponse,
} from "../parser";
import { Command, StandardResponseTypes } from "./base";

export class CapabilityCommand extends Command<CapabilityList> {
constructor() {
super("CAPABILITY");
}

protected parseResponse(
responses: StandardResponseTypes[],
): CapabilityList {
for (const resp of responses) {
if (
resp instanceof UntaggedResponse &&
resp.content instanceof CapabilityList
) {
return resp.content;
}

// It's also possible the server will just return it as a part
// of the tag response text, which is technically valid
if (
resp instanceof TaggedResponse &&
resp.status.text?.code instanceof CapabilityTextCode
) {
return resp.status.text.code.capabilities;
}
}
}
}
19 changes: 19 additions & 0 deletions src/commands/encoding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { IMAPError } from "../errors";

export function createIMAPSafeString(value: string | null, allowNull = false) {
if (value === null) {
if (!allowNull) {
throw new IMAPError(
"Cannot create IMAP safe string from null value",
);
}
return "NIL";
}

if (value.match(/\r|\n|[^\\]\\|"/)) {
// We have potentially unsafe characters, use a literal
return `${value.length}\r\n${value}`;
}
// Else just use DQUOTE
return `"${value}"`;
}
69 changes: 69 additions & 0 deletions src/commands/id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { IDResponse, UntaggedResponse } from "../parser";
import { Command, StandardResponseTypes } from "./base";
import { createIMAPSafeString } from "./encoding";

const pkg = require("../../package.json");

enum IdCommandKeys {
"address" = "address",
"arguments" = "arguments",
"command" = "command",
"environment" = "environment",
"date" = "date",
"name" = "name",
"os" = "os",
"os-version" = "os-version",
"support-url" = "support-url",
"vendor" = "vendor",
"version" = "version",
}
export type IdCommandValues = Partial<
{
[key in IdCommandKeys]: string | null;
}
>;

export type IdResponseMap = ReadonlyMap<string, null | string>;

const DEFAULT_ID_OPTS: IdCommandValues = {
name: "node-imap",
"support-url": `${pkg.bugs ? pkg.bugs.url || pkg.bugs : pkg.homepage}`,
vendor: "lovely-inbox",
version: pkg.version,
};

export class IdCommand extends Command<IdResponseMap> {
constructor(
protected readonly valuesToSend: IdCommandValues = DEFAULT_ID_OPTS,
) {
super("ID");
}

protected getCommand(): string {
if (!this.valuesToSend || !Object.keys(this.valuesToSend).length) {
return this.type;
}

const keyValPairs = [];
for (const [key, val] of Object.entries(this.valuesToSend)) {
if (key in IdCommandKeys) {
keyValPairs.push(createIMAPSafeString(key));
keyValPairs.push(createIMAPSafeString(val, true));
}
}

return `${this.type} (${keyValPairs.join(" ")})`;
}

protected parseResponse(responses: StandardResponseTypes[]): IdResponseMap {
for (const resp of responses) {
if (
resp instanceof UntaggedResponse &&
resp.content instanceof IDResponse
) {
return resp.content.details;
}
}
return new Map();
}
}
5 changes: 5 additions & 0 deletions src/commands/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export * from "./base";
export * from "./capability";
export * from "./id";
export * from "./noop";
export * from "./starttls";
Loading