mirror of
https://github.com/bitwarden/browser
synced 2025-01-04 14:22:50 +01:00
[PM-5841] add fastmail forwarder (#7676)
This commit is contained in:
parent
67f1fc4f95
commit
af4cafa2b9
@ -0,0 +1,255 @@
|
|||||||
|
/**
|
||||||
|
* include Request in test environment.
|
||||||
|
* @jest-environment ../../../../shared/test.environment.ts
|
||||||
|
*/
|
||||||
|
import { ApiService } from "../../../../abstractions/api.service";
|
||||||
|
import { Forwarders } from "../options/constants";
|
||||||
|
|
||||||
|
import { FastmailForwarder } from "./fastmail";
|
||||||
|
import { mockI18nService } from "./mocks.jest";
|
||||||
|
|
||||||
|
type MockResponse = { status: number; body: any };
|
||||||
|
|
||||||
|
// fastmail calls nativeFetch first to resolve the accountId,
|
||||||
|
// then it calls nativeFetch again to create the forwarding address.
|
||||||
|
// The common mock doesn't work here, because this test needs to return multiple responses
|
||||||
|
function mockApiService(accountId: MockResponse, forwardingAddress: MockResponse) {
|
||||||
|
function response(r: MockResponse) {
|
||||||
|
return {
|
||||||
|
status: r.status,
|
||||||
|
json: jest.fn().mockImplementation(() => Promise.resolve(r.body)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
nativeFetch: jest
|
||||||
|
.fn()
|
||||||
|
.mockImplementationOnce((r: Request) => response(accountId))
|
||||||
|
.mockImplementationOnce((r: Request) => response(forwardingAddress)),
|
||||||
|
} as unknown as ApiService;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EmptyResponse: MockResponse = Object.freeze({
|
||||||
|
status: 200,
|
||||||
|
body: Object.freeze({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const AccountIdSuccess: MockResponse = Object.freeze({
|
||||||
|
status: 200,
|
||||||
|
body: Object.freeze({
|
||||||
|
primaryAccounts: Object.freeze({
|
||||||
|
"https://www.fastmail.com/dev/maskedemail": "accountId",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// the tests
|
||||||
|
describe("Fastmail Forwarder", () => {
|
||||||
|
describe("generate(string | null, SelfHostedApiOptions & EmailDomainOptions)", () => {
|
||||||
|
it.each([null, ""])("throws an error if the token is missing (token = %p)", async (token) => {
|
||||||
|
const apiService = mockApiService(AccountIdSuccess, EmptyResponse);
|
||||||
|
const i18nService = mockI18nService();
|
||||||
|
|
||||||
|
const forwarder = new FastmailForwarder(apiService, i18nService);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
async () =>
|
||||||
|
await forwarder.generate(null, {
|
||||||
|
token,
|
||||||
|
domain: "example.com",
|
||||||
|
prefix: "prefix",
|
||||||
|
}),
|
||||||
|
).rejects.toEqual("forwaderInvalidToken");
|
||||||
|
|
||||||
|
expect(apiService.nativeFetch).not.toHaveBeenCalled();
|
||||||
|
expect(i18nService.t).toHaveBeenCalledWith("forwaderInvalidToken", Forwarders.Fastmail.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([401, 403])(
|
||||||
|
"throws a no account id error if the accountId request responds with a status other than 200",
|
||||||
|
async (status) => {
|
||||||
|
const apiService = mockApiService({ status, body: {} }, EmptyResponse);
|
||||||
|
const i18nService = mockI18nService();
|
||||||
|
|
||||||
|
const forwarder = new FastmailForwarder(apiService, i18nService);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
async () =>
|
||||||
|
await forwarder.generate(null, {
|
||||||
|
token: "token",
|
||||||
|
domain: "example.com",
|
||||||
|
prefix: "prefix",
|
||||||
|
}),
|
||||||
|
).rejects.toEqual("forwarderNoAccountId");
|
||||||
|
|
||||||
|
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
|
||||||
|
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
|
||||||
|
expect(i18nService.t).toHaveBeenCalledWith(
|
||||||
|
"forwarderNoAccountId",
|
||||||
|
Forwarders.Fastmail.name,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["jane.doe@example.com", 200],
|
||||||
|
["john.doe@example.com", 200],
|
||||||
|
])(
|
||||||
|
"returns the generated email address (= %p) if both requests are successful (status = %p)",
|
||||||
|
async (email, status) => {
|
||||||
|
const apiService = mockApiService(AccountIdSuccess, {
|
||||||
|
status,
|
||||||
|
body: {
|
||||||
|
methodResponses: [["MaskedEmail/set", { created: { "new-masked-email": { email } } }]],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const i18nService = mockI18nService();
|
||||||
|
|
||||||
|
const forwarder = new FastmailForwarder(apiService, i18nService);
|
||||||
|
|
||||||
|
const result = await forwarder.generate(null, {
|
||||||
|
token: "token",
|
||||||
|
domain: "example.com",
|
||||||
|
prefix: "prefix",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual(email);
|
||||||
|
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[
|
||||||
|
"It turned inside out!",
|
||||||
|
[
|
||||||
|
"MaskedEmail/set",
|
||||||
|
{ notCreated: { "new-masked-email": { description: "It turned inside out!" } } },
|
||||||
|
],
|
||||||
|
],
|
||||||
|
["And then it exploded!", ["error", { description: "And then it exploded!" }]],
|
||||||
|
])(
|
||||||
|
"throws a forwarder error (= %p) if both requests are successful (status = %p) but masked email creation fails",
|
||||||
|
async (description, response) => {
|
||||||
|
const apiService = mockApiService(AccountIdSuccess, {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
methodResponses: [response],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const i18nService = mockI18nService();
|
||||||
|
|
||||||
|
const forwarder = new FastmailForwarder(apiService, i18nService);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
async () =>
|
||||||
|
await forwarder.generate(null, {
|
||||||
|
token: "token",
|
||||||
|
domain: "example.com",
|
||||||
|
prefix: "prefix",
|
||||||
|
}),
|
||||||
|
).rejects.toEqual("forwarderError");
|
||||||
|
|
||||||
|
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
|
||||||
|
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
|
||||||
|
expect(i18nService.t).toHaveBeenCalledWith(
|
||||||
|
"forwarderError",
|
||||||
|
Forwarders.Fastmail.name,
|
||||||
|
description,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([401, 403])(
|
||||||
|
"throws an invalid token error if the jmap request fails with a %i",
|
||||||
|
async (status) => {
|
||||||
|
const apiService = mockApiService(AccountIdSuccess, { status, body: {} });
|
||||||
|
const i18nService = mockI18nService();
|
||||||
|
|
||||||
|
const forwarder = new FastmailForwarder(apiService, i18nService);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
async () =>
|
||||||
|
await forwarder.generate(null, {
|
||||||
|
token: "token",
|
||||||
|
domain: "example.com",
|
||||||
|
prefix: "prefix",
|
||||||
|
}),
|
||||||
|
).rejects.toEqual("forwaderInvalidToken");
|
||||||
|
|
||||||
|
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
|
||||||
|
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
|
||||||
|
expect(i18nService.t).toHaveBeenCalledWith(
|
||||||
|
"forwaderInvalidToken",
|
||||||
|
Forwarders.Fastmail.name,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
null,
|
||||||
|
[],
|
||||||
|
[[]],
|
||||||
|
[["MaskedEmail/not-a-real-op"]],
|
||||||
|
[["MaskedEmail/set", null]],
|
||||||
|
[["MaskedEmail/set", { created: null }]],
|
||||||
|
[["MaskedEmail/set", { created: { "new-masked-email": null } }]],
|
||||||
|
[["MaskedEmail/set", { notCreated: null }]],
|
||||||
|
[["MaskedEmail/set", { notCreated: { "new-masked-email": null } }]],
|
||||||
|
])(
|
||||||
|
"throws an unknown error if the jmap request is malformed (= %p)",
|
||||||
|
async (responses: any) => {
|
||||||
|
const apiService = mockApiService(AccountIdSuccess, {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
methodResponses: responses,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const i18nService = mockI18nService();
|
||||||
|
|
||||||
|
const forwarder = new FastmailForwarder(apiService, i18nService);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
async () =>
|
||||||
|
await forwarder.generate(null, {
|
||||||
|
token: "token",
|
||||||
|
domain: "example.com",
|
||||||
|
prefix: "prefix",
|
||||||
|
}),
|
||||||
|
).rejects.toEqual("forwarderUnknownError");
|
||||||
|
|
||||||
|
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
|
||||||
|
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
|
||||||
|
expect(i18nService.t).toHaveBeenCalledWith(
|
||||||
|
"forwarderUnknownError",
|
||||||
|
Forwarders.Fastmail.name,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([100, 202, 300, 418, 500, 600])(
|
||||||
|
"throws an unknown error if the request returns any other status code (= %i)",
|
||||||
|
async (statusCode) => {
|
||||||
|
const apiService = mockApiService(AccountIdSuccess, { status: statusCode, body: {} });
|
||||||
|
const i18nService = mockI18nService();
|
||||||
|
|
||||||
|
const forwarder = new FastmailForwarder(apiService, i18nService);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
async () =>
|
||||||
|
await forwarder.generate(null, {
|
||||||
|
token: "token",
|
||||||
|
domain: "example.com",
|
||||||
|
prefix: "prefix",
|
||||||
|
}),
|
||||||
|
).rejects.toEqual("forwarderUnknownError");
|
||||||
|
|
||||||
|
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
|
||||||
|
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
|
||||||
|
expect(i18nService.t).toHaveBeenCalledWith(
|
||||||
|
"forwarderUnknownError",
|
||||||
|
Forwarders.Fastmail.name,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
128
libs/common/src/tools/generator/username/forwarders/fastmail.ts
Normal file
128
libs/common/src/tools/generator/username/forwarders/fastmail.ts
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
import { ApiService } from "../../../../abstractions/api.service";
|
||||||
|
import { I18nService } from "../../../../platform/abstractions/i18n.service";
|
||||||
|
import { Forwarders } from "../options/constants";
|
||||||
|
import { EmailPrefixOptions, Forwarder, ApiOptions } from "../options/forwarder-options";
|
||||||
|
|
||||||
|
/** Generates a forwarding address for Fastmail */
|
||||||
|
export class FastmailForwarder implements Forwarder {
|
||||||
|
/** Instantiates the forwarder
|
||||||
|
* @param apiService used for ajax requests to the forwarding service
|
||||||
|
* @param i18nService used to look up error strings
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
private apiService: ApiService,
|
||||||
|
private i18nService: I18nService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** {@link Forwarder.generate} */
|
||||||
|
async generate(
|
||||||
|
website: string | null,
|
||||||
|
options: ApiOptions & EmailPrefixOptions,
|
||||||
|
): Promise<string> {
|
||||||
|
if (!options.token || options.token === "") {
|
||||||
|
const error = this.i18nService.t("forwaderInvalidToken", Forwarders.Fastmail.name);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountId = await this.getAccountId(options);
|
||||||
|
if (!accountId || accountId === "") {
|
||||||
|
const error = this.i18nService.t("forwarderNoAccountId", Forwarders.Fastmail.name);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = JSON.stringify({
|
||||||
|
using: ["https://www.fastmail.com/dev/maskedemail", "urn:ietf:params:jmap:core"],
|
||||||
|
methodCalls: [
|
||||||
|
[
|
||||||
|
"MaskedEmail/set",
|
||||||
|
{
|
||||||
|
accountId: accountId,
|
||||||
|
create: {
|
||||||
|
"new-masked-email": {
|
||||||
|
state: "enabled",
|
||||||
|
description: "",
|
||||||
|
forDomain: website,
|
||||||
|
emailPrefix: options.prefix,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"0",
|
||||||
|
],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const requestInit: RequestInit = {
|
||||||
|
redirect: "manual",
|
||||||
|
cache: "no-store",
|
||||||
|
method: "POST",
|
||||||
|
headers: new Headers({
|
||||||
|
Authorization: "Bearer " + options.token,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}),
|
||||||
|
body,
|
||||||
|
};
|
||||||
|
|
||||||
|
const url = "https://api.fastmail.com/jmap/api/";
|
||||||
|
const request = new Request(url, requestInit);
|
||||||
|
|
||||||
|
const response = await this.apiService.nativeFetch(request);
|
||||||
|
if (response.status === 200) {
|
||||||
|
const json = await response.json();
|
||||||
|
if (
|
||||||
|
json.methodResponses != null &&
|
||||||
|
json.methodResponses.length > 0 &&
|
||||||
|
json.methodResponses[0].length > 0
|
||||||
|
) {
|
||||||
|
if (json.methodResponses[0][0] === "MaskedEmail/set") {
|
||||||
|
if (json.methodResponses[0][1]?.created?.["new-masked-email"] != null) {
|
||||||
|
return json.methodResponses[0][1]?.created?.["new-masked-email"]?.email;
|
||||||
|
}
|
||||||
|
if (json.methodResponses[0][1]?.notCreated?.["new-masked-email"] != null) {
|
||||||
|
const errorDescription =
|
||||||
|
json.methodResponses[0][1]?.notCreated?.["new-masked-email"]?.description;
|
||||||
|
const error = this.i18nService.t(
|
||||||
|
"forwarderError",
|
||||||
|
Forwarders.Fastmail.name,
|
||||||
|
errorDescription,
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} else if (json.methodResponses[0][0] === "error") {
|
||||||
|
const errorDescription = json.methodResponses[0][1]?.description;
|
||||||
|
const error = this.i18nService.t(
|
||||||
|
"forwarderError",
|
||||||
|
Forwarders.Fastmail.name,
|
||||||
|
errorDescription,
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (response.status === 401 || response.status === 403) {
|
||||||
|
const error = this.i18nService.t("forwaderInvalidToken", Forwarders.Fastmail.name);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = this.i18nService.t("forwarderUnknownError", Forwarders.Fastmail.name);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAccountId(options: ApiOptions): Promise<string> {
|
||||||
|
const requestInit: RequestInit = {
|
||||||
|
cache: "no-store",
|
||||||
|
method: "GET",
|
||||||
|
headers: new Headers({
|
||||||
|
Authorization: "Bearer " + options.token,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const url = "https://api.fastmail.com/.well-known/jmap";
|
||||||
|
const request = new Request(url, requestInit);
|
||||||
|
const response = await this.apiService.nativeFetch(request);
|
||||||
|
if (response.status === 200) {
|
||||||
|
const json = await response.json();
|
||||||
|
if (json.primaryAccounts != null) {
|
||||||
|
return json.primaryAccounts["https://www.fastmail.com/dev/maskedemail"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user