diff --git a/libs/common/src/tools/generator/username/forwarders/fastmail.spec.ts b/libs/common/src/tools/generator/username/forwarders/fastmail.spec.ts new file mode 100644 index 0000000000..6d557399aa --- /dev/null +++ b/libs/common/src/tools/generator/username/forwarders/fastmail.spec.ts @@ -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, + ); + }, + ); + }); +}); diff --git a/libs/common/src/tools/generator/username/forwarders/fastmail.ts b/libs/common/src/tools/generator/username/forwarders/fastmail.ts new file mode 100644 index 0000000000..b6b40946a8 --- /dev/null +++ b/libs/common/src/tools/generator/username/forwarders/fastmail.ts @@ -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 { + 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 { + 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; + } +}