Skip to content

Commit

Permalink
enable persistent sessions support
Browse files Browse the repository at this point in the history
* the feature gets "experimental" label for now
  • Loading branch information
vladimiry committed Jan 21, 2020
1 parent badb1ea commit e7e8a0b
Show file tree
Hide file tree
Showing 46 changed files with 1,260 additions and 571 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ The way of verifying that the installation packages attached to the [releases](h
- :mailbox: **Multi accounts** support per each email provider including supporting individual [entry point domains](https://github.com/vladimiry/ElectronMail/issues/29).
- :unlock: **Automatic login into the app** with a remembered master password using [keytar](https://github.com/atom/node-keytar) module ([keep me signed in](images/keep-me-signed-in.png) feature).
- :unlock: **Automatic login into the email accounts**, including filling [2FA tokens](https://github.com/vladimiry/ElectronMail/issues/10). Two auto-login delay scenarios supported in order to make it harder to correlate the identities, see the respective [issue](https://github.com/vladimiry/ElectronMail/issues/121).
- :unlock: **Persistent email account sessions**. The feature introduced since [v4.2.0](https://github.com/vladimiry/ElectronMail/releases/tag/v4.2.0) version with the `experimental` label, [#227](https://github.com/vladimiry/ElectronMail/issues/227). The feature enables the scenario when you to enter the account credentials on the login form only once, manually or automatically by the app, and then you never see the login form anymore for this email account even if you restart the app (unless you explicitly dropped the session in the admin area or it got dropped by the service due to the inactivity/expiration). If this feature is enabled for the account, manual credentials filling is the preferred option as a more secure option since you don't save the account credentials anywhere (`credentials` are encrypted though event if saved, see `settings.bin` file description in the [FAQ](https://github.com/vladimiry/ElectronMail/wiki/FAQ)).
- :closed_lock_with_key: **Encrypted local storage** with switchable predefined key derivation and encryption presets. Argon2 is used as the default key derivation function.
- :bell: **Native notifications** for individual accounts clicking on which focuses the app window and selects respective account in the accounts list.
- :bell: **System tray icon** with a total number of unread messages shown on top of it. Enabling [local messages store](https://github.com/vladimiry/ElectronMail/issues/32) improves this feature ([how to enable](https://github.com/vladimiry/ElectronMail/releases/tag/v2.0.0-beta.1)), see respective [issue](https://github.com/vladimiry/ElectronMail/issues/30).
Expand Down Expand Up @@ -83,6 +84,7 @@ If you want to backup the app data these are only files you need to take care of
- `settings.bin` file keeps added to the app accounts including credentials if a user decided to save them. The file is encrypted with 32 bytes length key derived from the master password.
- `database.bin` file is a local database that keeps fetched emails/folders/contacts entities if the `local store` feature was enabled for at least one account. The file is encrypted with 32 bytes length key randomly generated and stored in `settings.bin`. The app by design flushes and loads to memory the `database.bin` file as a whole thing but not like encrypting only the specific columns of the database. It's of course not an optimal approach in terms of performance and resource consumption but it allows keeping the metadata hidden. You can see some details [here](https://github.com/vladimiry/ElectronMail/issues/32).
- `database-session.bin` file is being used in the same way and for the same purpose as `database.bin` but it holds the current session data only. The data from this file will be merged to the `database.bin` on the next app unlocking with the master password.
- `session.bin` file holds the session data of the email accounts. The file is used if the `Persistent Session` feature is enabled for at least one account (the feature introduced since [v4.2.0](https://github.com/vladimiry/ElectronMail/releases/tag/v4.2.0) version with `experimental` label, [#227](https://github.com/vladimiry/ElectronMail/issues/227)). The file is encrypted with 32 bytes length key randomly generated and stored in `settings.bin`.
- `log.log` file keeps log lines. The log level by default is set to `error` (see `config.json` file).

## Removing the app
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@
"start:electron": "electron ./app/electron-main.js",
"start:electron:dev": "electron ./app-dev/electron-main.js",
"test:e2e": "cross-env TS_NODE_FILES=true npx --no-install --node-arg=\"-r tsconfig-paths/register -r ts-node/register\" ava \"./src/e2e/**/*.{spec,test}.ts\"",
"test:electron-main": "cross-env TS_NODE_FILES=true TS_NODE_PROJECT=./src/electron-main/tsconfig.json npx --no-install --node-arg=\"-r tsconfig-paths/register -r ts-node/register\" ava \"./src/electron-main/**/*.{spec,test}.ts\"",
"test:electron-main": "yarn test:electron-main:base \"./src/electron-main/__test__/**/*.{spec,test}.ts\"",
"test:electron-main:base": "cross-env TS_NODE_FILES=true TS_NODE_PROJECT=./src/electron-main/__test__/tsconfig.json npx --no-install --node-arg=\"-r tsconfig-paths/register -r ts-node/register\" ava",
"test:web": "cross-env NODE_ENV=test TS_NODE_FILES=true npx --no-install --node-arg=\"-r tsconfig-paths/register\" karma start ./src/web/browser-window/test/karma.conf.ts --single-run",
"scripts/ci/appveyor/download-webclients-artifact": "cross-env TS_NODE_FILES=true ts-node -r tsconfig-paths/register scripts/ci/appveyor/download-webclients-artifact.ts",
"scripts/ci/print-webclients-dist-paths-pattern": "cross-env TS_NODE_FILES=true ts-node -r tsconfig-paths/register scripts/ci/print-webclients-dist-paths-pattern.ts",
Expand Down Expand Up @@ -119,7 +120,7 @@
"electron-unhandled": "3.0.2",
"fast-glob": "3.1.1",
"fs-extra": "8.1.0",
"fs-json-store": "2.3.1",
"fs-json-store": "2.3.3",
"fs-json-store-encryption-adapter": "1.3.6",
"html-to-text": "5.1.1",
"js-base64": "2.5.1",
Expand Down
16 changes: 13 additions & 3 deletions scripts/prepare-webclient/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import pathIsInside from "path-is-inside";
import {promisify} from "util";

import {CWD, LOG, LOG_LEVELS, execShell} from "scripts/lib";
import {PROVIDER_REPOS, WEB_CLIENTS_BLANK_HTML_FILE} from "src/shared/constants";
import {PROVIDER_REPOS, WEB_CLIENTS_BLANK_HTML_FILE_NAME} from "src/shared/constants";

const REPOS_ONLY_FILTER: ReadonlyArray<keyof typeof PROVIDER_REPOS> = (() => {
const {ELECTRON_MAIL_PREPARE_WEBCLIENTS_REPOS_ONLY} = process.env;
Expand Down Expand Up @@ -108,8 +108,18 @@ export async function execAccountTypeFlow<T extends FolderAsDomainEntry[], O = U
const flowArg = {repoDir, folderAsDomainEntry};

printAndWriteFile(
path.join(distDir, WEB_CLIENTS_BLANK_HTML_FILE),
``,
path.join(distDir, WEB_CLIENTS_BLANK_HTML_FILE_NAME),
`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
</body>
</html>
`,
);

if (await fsExtra.pathExists(repoDir)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,42 @@ import {buildSettingsAdapter} from "src/electron-main/util";

interface TestContext {
ctx: Context;
endpoints: IpcMainApiEndpoints;
endpoints: Skip<IpcMainApiEndpoints,
// TODO test skipped methods
| "activateBrowserWindow"
| "applySavedProtonBackendSession"
| "changeSpellCheckLocale"
| "dbExport"
| "dbFullTextSearch"
| "dbGetAccountDataView"
| "dbGetAccountMail"
| "dbGetAccountMetadata"
| "dbIndexerNotification"
| "dbIndexerOn"
| "dbPatch"
| "dbSearchRootConversationNodes"
| "findInPage"
| "findInPageDisplay"
| "findInPageNotification"
| "findInPageStop"
| "generateTOTPToken"
| "getSpellCheckMetadata"
| "hotkey"
| "loadDatabase"
| "notification"
| "reEncryptSettings"
| "resetProtonBackendSession"
| "resetSavedProtonSession"
| "resolveSavedProtonClientSession"
| "saveProtonSession"
| "selectAccount"
| "spellCheck"
| "staticInit"
| "toggleBrowserWindow"
| "toggleControls"
| "toggleLocalDbMailsListViewMode"
| "updateCheck"
| "updateOverlayIcon">;
mocks: Unpacked<ReturnType<typeof buildMocks>>;
}

Expand All @@ -40,19 +75,7 @@ const OPTIONS = Object.freeze({
masterPassword: "masterPassword123",
});

const tests: Record<keyof IpcMainApiEndpoints, (t: ExecutionContext<TestContext>) => ImplementationResult> = {
getSpellCheckMetadata: (t) => {
t.pass(`TODO test "getSpellCheckMetadata" endpoint`);
},

changeSpellCheckLocale: (t) => {
t.pass(`TODO test "changeSpellCheckLocale" endpoint`);
},

spellCheck: (t) => {
t.pass(`TODO test "spellCheck" endpoint`);
},

const tests: Record<keyof TestContext["endpoints"], (t: ExecutionContext<TestContext>) => ImplementationResult> = {
// TODO update "updateAccount" api method test (verify more fields)
addAccount: async (t) => {
const {
Expand Down Expand Up @@ -242,46 +265,6 @@ const tests: Record<keyof IpcMainApiEndpoints, (t: ExecutionContext<TestContext>
setPasswordSpy.calledWithExactly(payload.newPassword);
},

dbPatch: (t) => {
t.pass(`TODO test "dbPatch" endpoint`);
},

dbGetAccountMetadata: (t) => {
t.pass(`TODO test "dbGetAccountMetadata" endpoint`);
},

dbGetAccountDataView: (t) => {
t.pass(`TODO test "dbGetAccountMetadata" endpoint`);
},

dbGetAccountMail: (t) => {
t.pass(`TODO test "dbGetAccountMail" endpoint`);
},

dbExport: (t) => {
t.pass(`TODO test "dbExport" endpoint`);
},

dbSearchRootConversationNodes: (t) => {
t.pass(`TODO test "dbSearchRootConversationNodes" endpoint`);
},

dbFullTextSearch: (t) => {
t.pass(`TODO test "dbFullTextSearch" endpoint`);
},

dbIndexerOn: (t) => {
t.pass(`TODO test "dbIndexerOn" endpoint`);
},

dbIndexerNotification: (t) => {
t.pass(`TODO test "dbIndexerNotification" endpoint`);
},

staticInit: async (t) => {
t.pass(`TODO test "staticInit" endpoint`);
},

// TODO actualize "init" endpoint test
init: async (t) => {
const result = await t.context.endpoints.init();
Expand All @@ -293,7 +276,8 @@ const tests: Record<keyof IpcMainApiEndpoints, (t: ExecutionContext<TestContext>
const {endpoints} = t.context;
const dbResetSpy = sinon.spy(t.context.ctx.db, "reset");
const sessionDbResetSpy = sinon.spy(t.context.ctx.sessionDb, "reset");
const updateOverlayIconSpy = sinon.spy(endpoints, "updateOverlayIcon");
const sessionStorageResetSpy = sinon.spy(t.context.ctx.sessionStorage, "reset");
const updateOverlayIconSpy = sinon.spy(endpoints as any, "updateOverlayIcon");

await endpoints.logout();
t.falsy(t.context.ctx.settingsStore.adapter);
Expand All @@ -313,6 +297,7 @@ const tests: Record<keyof IpcMainApiEndpoints, (t: ExecutionContext<TestContext>

t.is(2, dbResetSpy.callCount);
t.is(2, sessionDbResetSpy.callCount);
t.is(2, sessionStorageResetSpy.callCount);
t.is(2, updateOverlayIconSpy.callCount);
},

Expand Down Expand Up @@ -460,32 +445,12 @@ const tests: Record<keyof IpcMainApiEndpoints, (t: ExecutionContext<TestContext>
t.is(initial.accounts.length + final.accounts.length, initSessionByAccountMock.callCount);
},

// TODO test "reEncryptSettings" API
reEncryptSettings: async (t) => {
t.pass();
},

settingsExists: async (t) => {
t.false(await t.context.ctx.settingsStore.readable(), "store: settings file does not exist");
await readConfigAndSettings(t.context.endpoints, {password: OPTIONS.masterPassword});
t.true(await t.context.ctx.settingsStore.readable(), "store: settings file exists");
},

// TODO test "loadDatabase" API
loadDatabase: (t) => {
t.pass();
},

// TODO test "activateBrowserWindow" API
activateBrowserWindow: (t) => {
t.pass();
},

// TODO test "toggleBrowserWindow" API
toggleBrowserWindow: (t) => {
t.pass();
},

toggleCompactLayout: async (t) => {
const endpoints = t.context.endpoints;
const action = endpoints.toggleCompactLayout;
Expand All @@ -500,78 +465,18 @@ const tests: Record<keyof IpcMainApiEndpoints, (t: ExecutionContext<TestContext>
const config3 = await t.context.ctx.configStore.readExisting();
t.is(config3.compactLayout, !config2.compactLayout);
},

// TODO test "generateTOTPToken" API
generateTOTPToken: (t) => {
t.pass();
},

// TODO test "updateOverlayIcon" API
updateOverlayIcon: async (t) => {
t.pass();
},

// TODO test "hotkey" API
selectAccount: (t) => {
t.pass();
},

// TODO test "hotkey" API
hotkey: (t) => {
t.pass();
},

// TODO test "findInPageDisplay" API
findInPageDisplay: (t) => {
t.pass();
},

// TODO test "findInPage" API
findInPage: (t) => {
t.pass();
},

// TODO test "findInPageStop" API
findInPageStop: (t) => {
t.pass();
},

// TODO test "findInPageNotification" API
findInPageNotification: (t) => {
t.pass();
},

// TODO test "updateCheck" API
updateCheck(t) {
t.pass();
},

// TODO test "toggleControls" API
toggleControls(t) {
t.pass();
},

// TODO test "toggleLocalDbMailsListViewMode" API
toggleLocalDbMailsListViewMode(t) {
t.pass();
},

// TODO test "notification" API
notification: (t) => {
t.pass();
},
};

Object.entries(tests).forEach(([apiMethodName, method]) => {
test.serial(apiMethodName, method);
});

async function readConfig(endpoints: IpcMainApiEndpoints): Promise<Config> {
async function readConfig(endpoints: TestContext["endpoints"]): Promise<Config> {
return await endpoints.readConfig();
}

async function readConfigAndSettings(
endpoints: IpcMainApiEndpoints, payload: PasswordFieldContainer & { savePassword?: boolean; supressErrors?: boolean },
endpoints: TestContext["endpoints"], payload: PasswordFieldContainer & { savePassword?: boolean; supressErrors?: boolean },
): Promise<Settings> {
await readConfig(endpoints);
return await endpoints.readSettings(payload);
Expand Down Expand Up @@ -656,7 +561,7 @@ test.beforeEach(async (t) => {
t.context.mocks = await buildMocks();

const mockedModule = await rewiremock.around(
() => import("./index"),
() => import("src/electron-main/api"),
(mock) => {
const {mocks} = t.context;
mock("electron").with(mocks.electron);
Expand All @@ -667,7 +572,7 @@ test.beforeEach(async (t) => {
mock(() => import("src/electron-main/session")).callThrough().with(mocks["src/electron-main/session"]);
mock(() => import("src/electron-main/util")).callThrough().with(mocks["src/electron-main/util"]);
mock(() => import("src/electron-main/storage-upgrade")).callThrough().with(mocks["src/electron-main/storage-upgrade"]);
mock(() => import("./endpoints-builders")).callThrough().with(mocks["./endpoints-builders"]);
mock(() => import("src/electron-main/api/endpoints-builders")).callThrough().with(mocks["./endpoints-builders"]);
},
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ function buildContext(): Pick<Context, "configStore"> {

async function loadLibrary(mocks: ReturnType<typeof buildMocks>) {
return await rewiremock.around(
() => import("./app-ready"),
() => import("src/electron-main/bootstrap/app-ready"),
(mock) => {
for (const [name, data] of Object.entries(mocks)) {
mock(name).with(data);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ function buildContext(fsImplPatch?: Partial<Store<Config>["fs"]["_impl"]>): Pick

async function loadLibrary(mocks: ReturnType<typeof buildMocks>) {
return rewiremock.around(
() => import("./command-line"),
() => import("src/electron-main/bootstrap/command-line"),
(mock) => {
for (const [name, data] of Object.entries(mocks)) {
mock(name).with(data);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ function buildMocks(

async function loadLibrary(mocks: ReturnType<typeof buildMocks>) {
return await rewiremock.around(
() => import("./init"),
() => import("src/electron-main/bootstrap/init"),
(mock) => {
for (const [name, data] of Object.entries(mocks)) {
mock(name).with(data);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import test from "ava";

import {INITIAL_STORES} from "./constants";
import {INITIAL_STORES} from "src/electron-main/constants";

test("INITIAL_STORES.config().logLevel", (t) => {
t.is("error", INITIAL_STORES.config().logLevel);
Expand All @@ -15,6 +15,19 @@ test("INITIAL_STORES.settings().databaseEncryptionKey call should return random
t.is(expectedSize, set.size);
});

test("INITIAL_STORES.settings().sessionStorageEncryptionKey call should return random data", (t) => {
const set: Set<string> = new Set();
const expectedSize = 100;
for (let i = 0; i < expectedSize; i++) {
set.add(INITIAL_STORES.settings().sessionStorageEncryptionKey);
}
t.is(expectedSize, set.size);
});

test("INITIAL_STORES.settings().databaseEncryptionKey should be 32 bytes length base64 encoded string", (t) => {
t.is(32, Buffer.from(INITIAL_STORES.settings().databaseEncryptionKey, "base64").length);
});

test("INITIAL_STORES.settings().sessionStorageEncryptionKey should be 32 bytes length base64 encoded string", (t) => {
t.is(32, Buffer.from(INITIAL_STORES.settings().sessionStorageEncryptionKey, "base64").length);
});
Loading

0 comments on commit e7e8a0b

Please sign in to comment.