[PM-5841] add fastmail forwarder (#7676)

This commit is contained in:
✨ Audrey ✨ 2024-01-25 10:23:56 -05:00 committed by GitHub
parent 67f1fc4f95
commit af4cafa2b9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 383 additions and 0 deletions

View File

@ -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,
);
},
);
});
});

View 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;
}
}