From 69839292b1c1eb8b373ff6e333319fd3aaa73ea8 Mon Sep 17 00:00:00 2001 From: John Harrington <84741727+harr1424@users.noreply.github.com> Date: Wed, 24 Dec 2025 08:07:47 -0700 Subject: [PATCH 01/16] WIP explore existing Send attributes and logic --- apps/cli/src/tools/send/commands/create.command.ts | 3 ++- apps/cli/src/tools/send/models/send.response.ts | 1 + apps/cli/src/tools/send/send.program.ts | 5 ++--- libs/common/src/tools/send/models/domain/send.ts | 1 + libs/common/src/tools/send/models/view/send.view.ts | 1 + 5 files changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/cli/src/tools/send/commands/create.command.ts b/apps/cli/src/tools/send/commands/create.command.ts index 7803f6f94d4..c6599a18cfc 100644 --- a/apps/cli/src/tools/send/commands/create.command.ts +++ b/apps/cli/src/tools/send/commands/create.command.ts @@ -87,6 +87,7 @@ export class SendCreateCommand { req.key = null; req.maxAccessCount = maxAccessCount; + req.emails = emails; //TODO should this be encrypted? const hasPremium$ = this.accountService.activeAccount$.pipe( switchMap(({ id }) => this.accountProfileService.hasPremiumFromAnySource$(id)), @@ -139,7 +140,7 @@ export class SendCreateCommand { // Add dates from template encSend.deletionDate = sendView.deletionDate; encSend.expirationDate = sendView.expirationDate; - encSend.emails = emails && emails.join(","); + encSend.emails = emails && emails.join(","); // TODO should this be encrypted await this.sendApiService.save([encSend, fileData]); const newSend = await this.sendService.getFromState(encSend.id); diff --git a/apps/cli/src/tools/send/models/send.response.ts b/apps/cli/src/tools/send/models/send.response.ts index a0c1d3f83c6..87f71cbf049 100644 --- a/apps/cli/src/tools/send/models/send.response.ts +++ b/apps/cli/src/tools/send/models/send.response.ts @@ -116,6 +116,7 @@ export class SendResponse implements BaseResponse { this.deletionDate = o.deletionDate; this.expirationDate = o.expirationDate; this.passwordSet = o.password != null; + this.emails = o.emails ?? []; this.disabled = o.disabled; this.hideEmail = o.hideEmail; diff --git a/apps/cli/src/tools/send/send.program.ts b/apps/cli/src/tools/send/send.program.ts index 33bf4518ccd..900131b9e2d 100644 --- a/apps/cli/src/tools/send/send.program.ts +++ b/apps/cli/src/tools/send/send.program.ts @@ -59,9 +59,7 @@ export class SendProgram extends BaseProgram { new Option( "--email ", "optional emails to access this Send. Can also be specified in JSON.", - ) - .argParser(parseEmail) - .hideHelp(), + ).argParser(parseEmail), ) .option("-a, --maxAccessCount ", "The amount of max possible accesses.") .option("--hidden", "Hide in web by default. Valid only if --file is not set.") @@ -328,6 +326,7 @@ export class SendProgram extends BaseProgram { file: sendFile, text: sendText, type: type, + emails: options.email ?? undefined, }); return Buffer.from(JSON.stringify(template), "utf8").toString("base64"); diff --git a/libs/common/src/tools/send/models/domain/send.ts b/libs/common/src/tools/send/models/domain/send.ts index 2bf16de8a44..bca3b4f7c6d 100644 --- a/libs/common/src/tools/send/models/domain/send.ts +++ b/libs/common/src/tools/send/models/domain/send.ts @@ -33,6 +33,7 @@ export class Send extends Domain { emails: string; disabled: boolean; hideEmail: boolean; + // TODO add authType enum constructor(obj?: SendData) { super(); diff --git a/libs/common/src/tools/send/models/view/send.view.ts b/libs/common/src/tools/send/models/view/send.view.ts index 54657b12438..def3d29d0d2 100644 --- a/libs/common/src/tools/send/models/view/send.view.ts +++ b/libs/common/src/tools/send/models/view/send.view.ts @@ -29,6 +29,7 @@ export class SendView implements View { emails: string[] = []; disabled = false; hideEmail = false; + // TODO add AuthType enum constructor(s?: Send) { if (!s) { From 913a6ad6665009c47a399db489833ef6b3b68937 Mon Sep 17 00:00:00 2001 From: John Harrington <84741727+harr1424@users.noreply.github.com> Date: Fri, 26 Dec 2025 11:36:20 -0700 Subject: [PATCH 02/16] WIP progress checkpoint Send refactor --- apps/cli/src/tools/send/commands/create.command.ts | 13 +++++++++++-- apps/cli/src/tools/send/models/send.response.ts | 3 +++ libs/common/src/tools/send/models/data/send.data.ts | 4 ++++ .../src/tools/send/models/domain/send.spec.ts | 6 +++++- libs/common/src/tools/send/models/domain/send.ts | 11 ++++++++++- .../src/tools/send/models/response/send.response.ts | 4 ++++ libs/common/src/tools/send/models/view/send.view.ts | 5 +++-- 7 files changed, 40 insertions(+), 6 deletions(-) diff --git a/apps/cli/src/tools/send/commands/create.command.ts b/apps/cli/src/tools/send/commands/create.command.ts index c6599a18cfc..eda20819942 100644 --- a/apps/cli/src/tools/send/commands/create.command.ts +++ b/apps/cli/src/tools/send/commands/create.command.ts @@ -10,6 +10,7 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { AuthType } from "@bitwarden/common/tools/send/models/domain/send"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; import { NodeUtils } from "@bitwarden/node/node-utils"; @@ -87,7 +88,15 @@ export class SendCreateCommand { req.key = null; req.maxAccessCount = maxAccessCount; - req.emails = emails; //TODO should this be encrypted? + req.emails = emails; + + if (emails != null && emails.length > 0) { + req.authType = AuthType.Email; + } else if (password != null && password.trim().length > 0) { + req.authType = AuthType.Password; + } else { + req.authType = AuthType.None; + } const hasPremium$ = this.accountService.activeAccount$.pipe( switchMap(({ id }) => this.accountProfileService.hasPremiumFromAnySource$(id)), @@ -140,7 +149,7 @@ export class SendCreateCommand { // Add dates from template encSend.deletionDate = sendView.deletionDate; encSend.expirationDate = sendView.expirationDate; - encSend.emails = emails && emails.join(","); // TODO should this be encrypted + encSend.emails = emails && emails.join(","); await this.sendApiService.save([encSend, fileData]); const newSend = await this.sendService.getFromState(encSend.id); diff --git a/apps/cli/src/tools/send/models/send.response.ts b/apps/cli/src/tools/send/models/send.response.ts index 87f71cbf049..c4f430c7803 100644 --- a/apps/cli/src/tools/send/models/send.response.ts +++ b/apps/cli/src/tools/send/models/send.response.ts @@ -2,6 +2,7 @@ // @ts-strict-ignore import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { AuthType } from "@bitwarden/common/tools/send/models/domain/send"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { BaseResponse } from "../../../models/response/base.response"; @@ -92,6 +93,7 @@ export class SendResponse implements BaseResponse { emails?: Array; disabled: boolean; hideEmail: boolean; + authType: AuthType; constructor(o?: SendView, webVaultUrl?: string) { if (o == null) { @@ -119,6 +121,7 @@ export class SendResponse implements BaseResponse { this.emails = o.emails ?? []; this.disabled = o.disabled; this.hideEmail = o.hideEmail; + this.authType = o.authType; if (o.type === SendType.Text && o.text != null) { this.text = new SendTextResponse(o.text); diff --git a/libs/common/src/tools/send/models/data/send.data.ts b/libs/common/src/tools/send/models/data/send.data.ts index 2c6377de0c9..80f50ab9a40 100644 --- a/libs/common/src/tools/send/models/data/send.data.ts +++ b/libs/common/src/tools/send/models/data/send.data.ts @@ -1,5 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { AuthType } from "@bitwarden/common/tools/send/models/domain/send"; + import { SendType } from "../../enums/send-type"; import { SendResponse } from "../response/send.response"; @@ -24,6 +26,7 @@ export class SendData { emails: string; disabled: boolean; hideEmail: boolean; + authType: AuthType; constructor(response?: SendResponse) { if (response == null) { @@ -45,6 +48,7 @@ export class SendData { this.emails = response.emails; this.disabled = response.disable; this.hideEmail = response.hideEmail; + this.authType = response.authType; switch (this.type) { case SendType.Text: diff --git a/libs/common/src/tools/send/models/domain/send.spec.ts b/libs/common/src/tools/send/models/domain/send.spec.ts index dc9ca7d3444..7be29216254 100644 --- a/libs/common/src/tools/send/models/domain/send.spec.ts +++ b/libs/common/src/tools/send/models/domain/send.spec.ts @@ -14,7 +14,7 @@ import { UserKey } from "../../../../types/key"; import { SendType } from "../../enums/send-type"; import { SendData } from "../data/send.data"; -import { Send } from "./send"; +import { AuthType, Send } from "./send"; import { SendText } from "./send-text"; describe("Send", () => { @@ -42,6 +42,7 @@ describe("Send", () => { emails: null!, disabled: false, hideEmail: true, + authType: AuthType.None, }; mockContainerService(); @@ -94,6 +95,7 @@ describe("Send", () => { emails: null!, disabled: false, hideEmail: true, + authType: AuthType.None, }); }); @@ -118,6 +120,7 @@ describe("Send", () => { send.password = "password"; send.disabled = false; send.hideEmail = true; + send.authType = AuthType.None; const encryptService = mock(); const keyService = mock(); @@ -157,6 +160,7 @@ describe("Send", () => { password: "password", disabled: false, hideEmail: true, + authType: AuthType.None, }); }); }); diff --git a/libs/common/src/tools/send/models/domain/send.ts b/libs/common/src/tools/send/models/domain/send.ts index bca3b4f7c6d..ea396df477b 100644 --- a/libs/common/src/tools/send/models/domain/send.ts +++ b/libs/common/src/tools/send/models/domain/send.ts @@ -15,6 +15,14 @@ import { SendView } from "../view/send.view"; import { SendFile } from "./send-file"; import { SendText } from "./send-text"; +export const AuthType = Object.freeze({ + Email: 0, + Password: 1, + None: 2, +} as const); + +export type AuthType = (typeof AuthType)[keyof typeof AuthType]; + export class Send extends Domain { id: string; accessId: string; @@ -33,7 +41,7 @@ export class Send extends Domain { emails: string; disabled: boolean; hideEmail: boolean; - // TODO add authType enum + authType: AuthType; constructor(obj?: SendData) { super(); @@ -64,6 +72,7 @@ export class Send extends Domain { this.deletionDate = obj.deletionDate != null ? new Date(obj.deletionDate) : null; this.expirationDate = obj.expirationDate != null ? new Date(obj.expirationDate) : null; this.hideEmail = obj.hideEmail; + this.authType = obj.authType; switch (this.type) { case SendType.Text: diff --git a/libs/common/src/tools/send/models/response/send.response.ts b/libs/common/src/tools/send/models/response/send.response.ts index 5c6bd4dc1a6..e05ea9ddd85 100644 --- a/libs/common/src/tools/send/models/response/send.response.ts +++ b/libs/common/src/tools/send/models/response/send.response.ts @@ -1,5 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { AuthType } from "@bitwarden/common/tools/send/models/domain/send"; + import { BaseResponse } from "../../../../models/response/base.response"; import { SendType } from "../../enums/send-type"; import { SendFileApi } from "../api/send-file.api"; @@ -23,6 +25,7 @@ export class SendResponse extends BaseResponse { emails: string; disable: boolean; hideEmail: boolean; + authType: AuthType; constructor(response: any) { super(response); @@ -41,6 +44,7 @@ export class SendResponse extends BaseResponse { this.emails = this.getResponseProperty("Emails"); this.disable = this.getResponseProperty("Disabled") || false; this.hideEmail = this.getResponseProperty("HideEmail") || false; + this.authType = this.getResponseProperty("AuthType"); const text = this.getResponseProperty("Text"); if (text != null) { diff --git a/libs/common/src/tools/send/models/view/send.view.ts b/libs/common/src/tools/send/models/view/send.view.ts index def3d29d0d2..4aa32971f4e 100644 --- a/libs/common/src/tools/send/models/view/send.view.ts +++ b/libs/common/src/tools/send/models/view/send.view.ts @@ -5,7 +5,7 @@ import { Utils } from "../../../../platform/misc/utils"; import { SymmetricCryptoKey } from "../../../../platform/models/domain/symmetric-crypto-key"; import { DeepJsonify } from "../../../../types/deep-jsonify"; import { SendType } from "../../enums/send-type"; -import { Send } from "../domain/send"; +import { AuthType, Send } from "../domain/send"; import { SendFileView } from "./send-file.view"; import { SendTextView } from "./send-text.view"; @@ -29,7 +29,7 @@ export class SendView implements View { emails: string[] = []; disabled = false; hideEmail = false; - // TODO add AuthType enum + authType: AuthType = null; constructor(s?: Send) { if (!s) { @@ -47,6 +47,7 @@ export class SendView implements View { this.disabled = s.disabled; this.password = s.password; this.hideEmail = s.hideEmail; + this.authType = s.authType; } get urlB64Key(): string { From e8794cf9afaad7d74f42e79770e48a419327da2b Mon Sep 17 00:00:00 2001 From: John Harrington <84741727+harr1424@users.noreply.github.com> Date: Fri, 26 Dec 2025 19:19:11 -0700 Subject: [PATCH 03/16] send creation integrated with server and returns object containing emails --- libs/common/src/tools/send/models/view/send.view.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/libs/common/src/tools/send/models/view/send.view.ts b/libs/common/src/tools/send/models/view/send.view.ts index 4aa32971f4e..baa2fe9f73b 100644 --- a/libs/common/src/tools/send/models/view/send.view.ts +++ b/libs/common/src/tools/send/models/view/send.view.ts @@ -48,6 +48,12 @@ export class SendView implements View { this.password = s.password; this.hideEmail = s.hideEmail; this.authType = s.authType; + this.emails = s.emails + ? s.emails + .split(",") + .map((e) => e.trim()) + .filter((e) => e) + : []; } get urlB64Key(): string { From 705bc2b730aeb4191bebea889ca877617d6dd868 Mon Sep 17 00:00:00 2001 From: John Harrington <84741727+harr1424@users.noreply.github.com> Date: Mon, 29 Dec 2025 19:05:43 -0700 Subject: [PATCH 05/16] WIP respond to Claude & add tests --- .../send/commands/create.command.spec.ts | 365 ++++++++++++++++ .../src/tools/send/commands/create.command.ts | 1 + .../tools/send/commands/edit.command.spec.ts | 400 ++++++++++++++++++ .../src/tools/send/commands/edit.command.ts | 27 +- .../src/tools/send/models/send.response.ts | 1 + 5 files changed, 790 insertions(+), 4 deletions(-) create mode 100644 apps/cli/src/tools/send/commands/create.command.spec.ts create mode 100644 apps/cli/src/tools/send/commands/edit.command.spec.ts diff --git a/apps/cli/src/tools/send/commands/create.command.spec.ts b/apps/cli/src/tools/send/commands/create.command.spec.ts new file mode 100644 index 00000000000..f7cdd58c8a0 --- /dev/null +++ b/apps/cli/src/tools/send/commands/create.command.spec.ts @@ -0,0 +1,365 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; +import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { AuthType } from "@bitwarden/common/tools/send/models/domain/send"; +import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { UserId } from "@bitwarden/user-core"; + +import { SendCreateCommand } from "./create.command"; + +describe("SendCreateCommand", () => { + let command: SendCreateCommand; + + const sendService = mock(); + const environmentService = mock(); + const sendApiService = mock(); + const accountProfileService = mock(); + const accountService = mock(); + + const activeAccount = { + id: "user-id" as UserId, + ...mockAccountInfoWith({ + email: "user@example.com", + name: "User", + }), + }; + + beforeEach(() => { + jest.clearAllMocks(); + + accountService.activeAccount$ = of(activeAccount); + accountProfileService.hasPremiumFromAnySource$.mockReturnValue(of(false)); + environmentService.environment$ = of({ + getWebVaultUrl: () => "https://vault.bitwarden.com", + } as any); + + command = new SendCreateCommand( + sendService, + environmentService, + sendApiService, + accountProfileService, + accountService, + ); + }); + + describe("authType inference", () => { + const futureDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); + + describe("with CLI flags", () => { + it("should set authType to Email when emails are provided via CLI", async () => { + const requestJson = { + type: SendType.Text, + text: { text: "test content", hidden: false }, + deletionDate: futureDate, + }; + + const cmdOptions = { + email: ["test@example.com"], + }; + + sendService.encrypt.mockResolvedValue([ + { id: "send-id", emails: "test@example.com", authType: AuthType.Email } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + sendService.getFromState.mockResolvedValue({ + decrypt: jest.fn().mockResolvedValue({}), + } as any); + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(true); + expect(sendService.encrypt).toHaveBeenCalledWith( + expect.objectContaining({ + type: SendType.Text, + }), + null, + undefined, + ); + }); + + it("should set authType to Password when password is provided via CLI", async () => { + const requestJson = { + type: SendType.Text, + text: { text: "test content", hidden: false }, + deletionDate: futureDate, + }; + + const cmdOptions = { + password: "testPassword123", + }; + + sendService.encrypt.mockResolvedValue([ + { id: "send-id", authType: AuthType.Password } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + sendService.getFromState.mockResolvedValue({ + decrypt: jest.fn().mockResolvedValue({}), + } as any); + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(true); + expect(sendService.encrypt).toHaveBeenCalledWith( + expect.any(Object), + null as any, + "testPassword123", + ); + }); + + it("should set authType to None when neither emails nor password provided", async () => { + const requestJson = { + type: SendType.Text, + text: { text: "test content", hidden: false }, + deletionDate: futureDate, + }; + + const cmdOptions = {}; + + sendService.encrypt.mockResolvedValue([ + { id: "send-id", authType: AuthType.None } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + sendService.getFromState.mockResolvedValue({ + decrypt: jest.fn().mockResolvedValue({}), + } as any); + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(true); + expect(sendService.encrypt).toHaveBeenCalledWith(expect.any(Object), null, undefined); + }); + + it("should return error when both emails and password provided via CLI", async () => { + const requestJson = { + type: SendType.Text, + text: { text: "test content", hidden: false }, + deletionDate: futureDate, + }; + + const cmdOptions = { + email: ["test@example.com"], + password: "testPassword123", + }; + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(false); + expect(response.message).toBe("--password and --emails are mutually exclusive."); + }); + }); + + describe("with JSON input", () => { + it("should set authType to Email when emails provided in JSON", async () => { + const requestJson = { + type: SendType.Text, + text: { text: "test content", hidden: false }, + deletionDate: futureDate, + emails: ["test@example.com", "another@example.com"], + }; + + sendService.encrypt.mockResolvedValue([ + { + id: "send-id", + emails: "test@example.com,another@example.com", + authType: AuthType.Email, + } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + sendService.getFromState.mockResolvedValue({ + decrypt: jest.fn().mockResolvedValue({}), + } as any); + + const response = await command.run(requestJson, {}); + + expect(response.success).toBe(true); + }); + + it("should set authType to Password when password provided in JSON", async () => { + const requestJson = { + type: SendType.Text, + text: { text: "test content", hidden: false }, + deletionDate: futureDate, + password: "jsonPassword123", + }; + + sendService.encrypt.mockResolvedValue([ + { id: "send-id", authType: AuthType.Password } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + sendService.getFromState.mockResolvedValue({ + decrypt: jest.fn().mockResolvedValue({}), + } as any); + + const response = await command.run(requestJson, {}); + + expect(response.success).toBe(true); + }); + + it("should return error when both emails and password provided in JSON", async () => { + const requestJson = { + type: SendType.Text, + text: { text: "test content", hidden: false }, + deletionDate: futureDate, + emails: ["test@example.com"], + password: "jsonPassword123", + }; + + const response = await command.run(requestJson, {}); + + expect(response.success).toBe(false); + expect(response.message).toBe("--password and --emails are mutually exclusive."); + }); + }); + + describe("with mixed CLI and JSON input", () => { + it("should return error when CLI emails combined with JSON password", async () => { + const requestJson = { + type: SendType.Text, + text: { text: "test content", hidden: false }, + deletionDate: futureDate, + password: "jsonPassword123", + }; + + const cmdOptions = { + email: ["cli@example.com"], + }; + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(false); + expect(response.message).toBe("--password and --emails are mutually exclusive."); + }); + + it("should return error when CLI password combined with JSON emails", async () => { + const requestJson = { + type: SendType.Text, + text: { text: "test content", hidden: false }, + deletionDate: futureDate, + emails: ["json@example.com"], + }; + + const cmdOptions = { + password: "cliPassword123", + }; + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(false); + expect(response.message).toBe("--password and --emails are mutually exclusive."); + }); + + it("should use CLI value when JSON has different value of same type", async () => { + const requestJson = { + type: SendType.Text, + text: { text: "test content", hidden: false }, + deletionDate: futureDate, + emails: ["json@example.com"], + }; + + const cmdOptions = { + email: ["cli@example.com"], + }; + + sendService.encrypt.mockResolvedValue([ + { id: "send-id", emails: "cli@example.com", authType: AuthType.Email } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + sendService.getFromState.mockResolvedValue({ + decrypt: jest.fn().mockResolvedValue({}), + } as any); + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(true); + }); + }); + + describe("edge cases", () => { + it("should set authType to None when emails array is empty", async () => { + const requestJson = { + type: SendType.Text, + text: { text: "test content", hidden: false }, + deletionDate: futureDate, + emails: [] as string[], + }; + + sendService.encrypt.mockResolvedValue([ + { id: "send-id", authType: AuthType.None } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + sendService.getFromState.mockResolvedValue({ + decrypt: jest.fn().mockResolvedValue({}), + } as any); + + const response = await command.run(requestJson, {}); + + expect(response.success).toBe(true); + }); + + it("should set authType to None when password is empty string", async () => { + const requestJson = { + type: SendType.Text, + text: { text: "test content", hidden: false }, + deletionDate: futureDate, + }; + + const cmdOptions = { + password: "", + }; + + sendService.encrypt.mockResolvedValue([ + { id: "send-id", authType: AuthType.None } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + sendService.getFromState.mockResolvedValue({ + decrypt: jest.fn().mockResolvedValue({}), + } as any); + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(true); + }); + + it("should set authType to None when password is whitespace only", async () => { + const requestJson = { + type: SendType.Text, + text: { text: "test content", hidden: false }, + deletionDate: futureDate, + }; + + const cmdOptions = { + password: " ", + }; + + sendService.encrypt.mockResolvedValue([ + { id: "send-id", authType: AuthType.None } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + sendService.getFromState.mockResolvedValue({ + decrypt: jest.fn().mockResolvedValue({}), + } as any); + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(true); + }); + }); + }); +}); diff --git a/apps/cli/src/tools/send/commands/create.command.ts b/apps/cli/src/tools/send/commands/create.command.ts index eda20819942..ade6547821b 100644 --- a/apps/cli/src/tools/send/commands/create.command.ts +++ b/apps/cli/src/tools/send/commands/create.command.ts @@ -150,6 +150,7 @@ export class SendCreateCommand { encSend.deletionDate = sendView.deletionDate; encSend.expirationDate = sendView.expirationDate; encSend.emails = emails && emails.join(","); + encSend.authType = req.authType; await this.sendApiService.save([encSend, fileData]); const newSend = await this.sendService.getFromState(encSend.id); diff --git a/apps/cli/src/tools/send/commands/edit.command.spec.ts b/apps/cli/src/tools/send/commands/edit.command.spec.ts new file mode 100644 index 00000000000..1ccd9785b1f --- /dev/null +++ b/apps/cli/src/tools/send/commands/edit.command.spec.ts @@ -0,0 +1,400 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; +import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { AuthType } from "@bitwarden/common/tools/send/models/domain/send"; +import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { UserId } from "@bitwarden/user-core"; + +import { Response } from "../../../models/response"; +import { SendResponse } from "../models/send.response"; + +import { SendEditCommand } from "./edit.command"; +import { SendGetCommand } from "./get.command"; + +describe("SendEditCommand", () => { + let command: SendEditCommand; + + const sendService = mock(); + const getCommand = mock(); + const sendApiService = mock(); + const accountProfileService = mock(); + const accountService = mock(); + + const activeAccount = { + id: "user-id" as UserId, + ...mockAccountInfoWith({ + email: "user@example.com", + name: "User", + }), + }; + + const mockSendId = "send-123"; + const mockSendView = { + id: mockSendId, + type: SendType.Text, + name: "Test Send", + text: { text: "test content", hidden: false }, + deletionDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + } as SendView; + + const mockSend = { + id: mockSendId, + type: SendType.Text, + decrypt: jest.fn().mockResolvedValue(mockSendView), + }; + + const encodeRequest = (data: any) => Buffer.from(JSON.stringify(data)).toString("base64"); + + beforeEach(() => { + jest.clearAllMocks(); + + accountService.activeAccount$ = of(activeAccount); + accountProfileService.hasPremiumFromAnySource$.mockReturnValue(of(false)); + sendService.getFromState.mockResolvedValue(mockSend as any); + getCommand.run.mockResolvedValue(Response.success(new SendResponse(mockSendView)) as any); + + command = new SendEditCommand( + sendService, + getCommand, + sendApiService, + accountProfileService, + accountService, + ); + }); + + describe("authType inference", () => { + describe("with CLI flags", () => { + it("should set authType to Email when emails are provided via CLI", async () => { + const requestData = { + id: mockSendId, + type: SendType.Text, + name: "Test Send", + }; + const requestJson = encodeRequest(requestData); + + const cmdOptions = { + email: ["test@example.com"], + }; + + sendService.encrypt.mockResolvedValue([ + { id: mockSendId, emails: "test@example.com", authType: AuthType.Email } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.Email); + expect(savedCall[0].emails).toBe("test@example.com"); + }); + + it("should set authType to Password when password is provided via CLI", async () => { + const requestData = { + id: mockSendId, + type: SendType.Text, + name: "Test Send", + }; + const requestJson = encodeRequest(requestData); + + const cmdOptions = { + password: "testPassword123", + }; + + sendService.encrypt.mockResolvedValue([ + { id: mockSendId, authType: AuthType.Password } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.Password); + }); + + it("should set authType to None when neither emails nor password provided", async () => { + const requestData = { + id: mockSendId, + type: SendType.Text, + name: "Test Send", + }; + const requestJson = encodeRequest(requestData); + + const cmdOptions = {}; + + sendService.encrypt.mockResolvedValue([ + { id: mockSendId, authType: AuthType.None } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.None); + }); + + it("should return error when both emails and password provided via CLI", async () => { + const requestData = { + id: mockSendId, + type: SendType.Text, + name: "Test Send", + }; + const requestJson = encodeRequest(requestData); + + const cmdOptions = { + email: ["test@example.com"], + password: "testPassword123", + }; + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(false); + expect(response.message).toBe("--password and --emails are mutually exclusive."); + }); + }); + + describe("with JSON input", () => { + it("should set authType to Email when emails provided in JSON", async () => { + const requestData = { + id: mockSendId, + type: SendType.Text, + name: "Test Send", + emails: ["test@example.com", "another@example.com"], + }; + const requestJson = encodeRequest(requestData); + + sendService.encrypt.mockResolvedValue([ + { id: mockSendId, authType: AuthType.Password } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + + const response = await command.run(requestJson, {}); + + expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.Email); + }); + + it("should set authType to Password when password provided in JSON", async () => { + const requestData = { + id: mockSendId, + type: SendType.Text, + name: "Test Send", + password: "jsonPassword123", + }; + const requestJson = encodeRequest(requestData); + + sendService.encrypt.mockResolvedValue([ + { id: mockSendId, authType: AuthType.Password } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + + const response = await command.run(requestJson, {}); + + expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.Password); + }); + + it("should return error when both emails and password provided in JSON", async () => { + const requestData = { + id: mockSendId, + type: SendType.Text, + name: "Test Send", + emails: ["test@example.com"], + password: "jsonPassword123", + }; + const requestJson = encodeRequest(requestData); + + const response = await command.run(requestJson, {}); + + expect(response.success).toBe(false); + expect(response.message).toBe("--password and --emails are mutually exclusive."); + }); + }); + + describe("with mixed CLI and JSON input", () => { + it("should return error when CLI emails combined with JSON password", async () => { + const requestData = { + id: mockSendId, + type: SendType.Text, + name: "Test Send", + password: "jsonPassword123", + }; + const requestJson = encodeRequest(requestData); + + const cmdOptions = { + email: ["cli@example.com"], + }; + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(false); + expect(response.message).toBe("--password and --emails are mutually exclusive."); + }); + + it("should return error when CLI password combined with JSON emails", async () => { + const requestData = { + id: mockSendId, + type: SendType.Text, + name: "Test Send", + emails: ["json@example.com"], + }; + const requestJson = encodeRequest(requestData); + + const cmdOptions = { + password: "cliPassword123", + }; + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(false); + expect(response.message).toBe("--password and --emails are mutually exclusive."); + }); + + it("should prioritize CLI value when JSON has different value of same type", async () => { + const requestData = { + id: mockSendId, + type: SendType.Text, + name: "Test Send", + emails: ["json@example.com"], + }; + const requestJson = encodeRequest(requestData); + + const cmdOptions = { + email: ["cli@example.com"], + }; + + sendService.encrypt.mockResolvedValue([ + { id: mockSendId, emails: "cli@example.com", authType: AuthType.Email } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.Email); + expect(savedCall[0].emails).toBe("cli@example.com"); + }); + }); + + describe("edge cases", () => { + it("should set authType to None when emails array is empty", async () => { + const requestData = { + id: mockSendId, + type: SendType.Text, + name: "Test Send", + emails: [] as string[], + }; + const requestJson = encodeRequest(requestData); + + sendService.encrypt.mockResolvedValue([ + { id: mockSendId, authType: AuthType.None } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + + const response = await command.run(requestJson, {}); + + expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.None); + }); + + it("should set authType to None when password is empty string", async () => { + const requestData = { + id: mockSendId, + type: SendType.Text, + name: "Test Send", + password: "", + }; + const requestJson = encodeRequest(requestData); + + sendService.encrypt.mockResolvedValue([ + { id: mockSendId, authType: AuthType.None } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + + const response = await command.run(requestJson, {}); + + expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.None); + }); + + it("should handle send not found", async () => { + sendService.getFromState.mockResolvedValue(null); + + const requestData = { + id: "nonexistent-id", + type: SendType.Text, + name: "Test Send", + }; + const requestJson = encodeRequest(requestData); + + const response = await command.run(requestJson, {}); + + expect(response.success).toBe(false); + }); + + it("should handle type mismatch", async () => { + const requestData = { + id: mockSendId, + type: SendType.File, + name: "Test Send", + }; + const requestJson = encodeRequest(requestData); + + const response = await command.run(requestJson, {}); + + expect(response.success).toBe(false); + expect(response.message).toBe("Cannot change a Send's type"); + }); + }); + }); + + describe("validation", () => { + it("should return error when requestJson is empty", async () => { + // Set BW_SERVE to prevent readStdin call + process.env.BW_SERVE = "true"; + + const response = await command.run("", {}); + + expect(response.success).toBe(false); + expect(response.message).toBe("`requestJson` was not provided."); + + delete process.env.BW_SERVE; + }); + + it("should return error when id is not provided", async () => { + const requestData = { + type: SendType.Text, + name: "Test Send", + }; + const requestJson = encodeRequest(requestData); + + const response = await command.run(requestJson, {}); + + expect(response.success).toBe(false); + expect(response.message).toBe("`itemid` was not provided."); + }); + }); +}); diff --git a/apps/cli/src/tools/send/commands/edit.command.ts b/apps/cli/src/tools/send/commands/edit.command.ts index bf53c8a5cb9..1d49e0ae6c2 100644 --- a/apps/cli/src/tools/send/commands/edit.command.ts +++ b/apps/cli/src/tools/send/commands/edit.command.ts @@ -6,6 +6,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { AuthType } from "@bitwarden/common/tools/send/models/domain/send"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; @@ -53,14 +54,30 @@ export class SendEditCommand { req.id = normalizedOptions.itemId || req.id; if (normalizedOptions.emails) { req.emails = normalizedOptions.emails; - req.password = undefined; - } else if (normalizedOptions.password) { - req.emails = undefined; + } + if (normalizedOptions.password) { req.password = normalizedOptions.password; - } else if (req.password && (typeof req.password !== "string" || req.password === "")) { + } + if (req.password && (typeof req.password !== "string" || req.password === "")) { req.password = undefined; } + // Infer authType based on emails/password (mutually exclusive) + const hasEmails = req.emails != null && req.emails.length > 0; + const hasPassword = req.password != null && req.password !== ""; + + if (hasEmails && hasPassword) { + return Response.badRequest("--password and --emails are mutually exclusive."); + } + + if (hasEmails) { + req.authType = AuthType.Email; + } else if (hasPassword) { + req.authType = AuthType.Password; + } else { + req.authType = AuthType.None; + } + if (!req.id) { return Response.error("`itemid` was not provided."); } @@ -93,6 +110,8 @@ export class SendEditCommand { // Add dates from template encSend.deletionDate = sendView.deletionDate; encSend.expirationDate = sendView.expirationDate; + encSend.emails = req.emails && req.emails.join(","); + encSend.authType = req.authType; await this.sendApiService.save([encSend, encFileData]); } catch (e) { diff --git a/apps/cli/src/tools/send/models/send.response.ts b/apps/cli/src/tools/send/models/send.response.ts index c4f430c7803..a76f7bcd820 100644 --- a/apps/cli/src/tools/send/models/send.response.ts +++ b/apps/cli/src/tools/send/models/send.response.ts @@ -55,6 +55,7 @@ export class SendResponse implements BaseResponse { view.emails = send.emails ?? []; view.disabled = send.disabled; view.hideEmail = send.hideEmail; + view.authType = send.authType; return view; } From 0fd81faabb3de5b48a8a9909d8198f55c94f05c0 Mon Sep 17 00:00:00 2001 From: John Harrington <84741727+harr1424@users.noreply.github.com> Date: Tue, 30 Dec 2025 09:09:08 -0700 Subject: [PATCH 06/16] improve mutual exclusion check for create with emails and password --- apps/cli/src/tools/send/commands/create.command.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/tools/send/commands/create.command.ts b/apps/cli/src/tools/send/commands/create.command.ts index ade6547821b..268ff862d4f 100644 --- a/apps/cli/src/tools/send/commands/create.command.ts +++ b/apps/cli/src/tools/send/commands/create.command.ts @@ -82,7 +82,10 @@ export class SendCreateCommand { const emails = req.emails ?? options.emails ?? undefined; const maxAccessCount = req.maxAccessCount ?? options.maxAccessCount; - if (emails !== undefined && password !== undefined) { + const hasEmails = emails != null && emails.length > 0; + const hasPassword = password != null && password.trim().length > 0; + + if (hasEmails && hasPassword) { return Response.badRequest("--password and --emails are mutually exclusive."); } From 07eb16d8ec150781cd5f86a6f4251bcdf77768b8 Mon Sep 17 00:00:00 2001 From: John Harrington <84741727+harr1424@users.noreply.github.com> Date: Tue, 30 Dec 2025 16:12:02 -0700 Subject: [PATCH 07/16] respond to review comments --- .../send/commands/create.command.spec.ts | 21 +++++++++++++++++++ .../src/tools/send/commands/create.command.ts | 10 ++------- .../tools/send/commands/edit.command.spec.ts | 2 +- .../src/tools/send/commands/edit.command.ts | 6 ------ .../src/tools/send/models/view/send.view.ts | 7 +------ .../src/tools/send/services/send.service.ts | 2 ++ 6 files changed, 27 insertions(+), 21 deletions(-) diff --git a/apps/cli/src/tools/send/commands/create.command.spec.ts b/apps/cli/src/tools/send/commands/create.command.spec.ts index f7cdd58c8a0..f53c71dc345 100644 --- a/apps/cli/src/tools/send/commands/create.command.spec.ts +++ b/apps/cli/src/tools/send/commands/create.command.spec.ts @@ -84,6 +84,9 @@ describe("SendCreateCommand", () => { null, undefined, ); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.Email); + expect(savedCall[0].emails).toBe("test@example.com"); }); it("should set authType to Password when password is provided via CLI", async () => { @@ -114,6 +117,8 @@ describe("SendCreateCommand", () => { null as any, "testPassword123", ); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.Password); }); it("should set authType to None when neither emails nor password provided", async () => { @@ -138,6 +143,8 @@ describe("SendCreateCommand", () => { expect(response.success).toBe(true); expect(sendService.encrypt).toHaveBeenCalledWith(expect.any(Object), null, undefined); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.None); }); it("should return error when both emails and password provided via CLI", async () => { @@ -184,6 +191,9 @@ describe("SendCreateCommand", () => { const response = await command.run(requestJson, {}); expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.Email); + expect(savedCall[0].emails).toBe("test@example.com,another@example.com"); }); it("should set authType to Password when password provided in JSON", async () => { @@ -206,6 +216,8 @@ describe("SendCreateCommand", () => { const response = await command.run(requestJson, {}); expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.Password); }); it("should return error when both emails and password provided in JSON", async () => { @@ -285,6 +297,9 @@ describe("SendCreateCommand", () => { const response = await command.run(requestJson, cmdOptions); expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.Email); + expect(savedCall[0].emails).toBe("cli@example.com"); }); }); @@ -309,6 +324,8 @@ describe("SendCreateCommand", () => { const response = await command.run(requestJson, {}); expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.None); }); it("should set authType to None when password is empty string", async () => { @@ -334,6 +351,8 @@ describe("SendCreateCommand", () => { const response = await command.run(requestJson, cmdOptions); expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.None); }); it("should set authType to None when password is whitespace only", async () => { @@ -359,6 +378,8 @@ describe("SendCreateCommand", () => { const response = await command.run(requestJson, cmdOptions); expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.None); }); }); }); diff --git a/apps/cli/src/tools/send/commands/create.command.ts b/apps/cli/src/tools/send/commands/create.command.ts index 268ff862d4f..b8e3c801310 100644 --- a/apps/cli/src/tools/send/commands/create.command.ts +++ b/apps/cli/src/tools/send/commands/create.command.ts @@ -93,9 +93,9 @@ export class SendCreateCommand { req.maxAccessCount = maxAccessCount; req.emails = emails; - if (emails != null && emails.length > 0) { + if (hasEmails) { req.authType = AuthType.Email; - } else if (password != null && password.trim().length > 0) { + } else if (hasPassword) { req.authType = AuthType.Password; } else { req.authType = AuthType.None; @@ -149,12 +149,6 @@ export class SendCreateCommand { const sendView = SendResponse.toView(req); const [encSend, fileData] = await this.sendService.encrypt(sendView, fileBuffer, password); - // Add dates from template - encSend.deletionDate = sendView.deletionDate; - encSend.expirationDate = sendView.expirationDate; - encSend.emails = emails && emails.join(","); - encSend.authType = req.authType; - await this.sendApiService.save([encSend, fileData]); const newSend = await this.sendService.getFromState(encSend.id); const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); diff --git a/apps/cli/src/tools/send/commands/edit.command.spec.ts b/apps/cli/src/tools/send/commands/edit.command.spec.ts index 1ccd9785b1f..b1d57517641 100644 --- a/apps/cli/src/tools/send/commands/edit.command.spec.ts +++ b/apps/cli/src/tools/send/commands/edit.command.spec.ts @@ -177,7 +177,7 @@ describe("SendEditCommand", () => { const requestJson = encodeRequest(requestData); sendService.encrypt.mockResolvedValue([ - { id: mockSendId, authType: AuthType.Password } as any, + { id: mockSendId, authType: AuthType.Email } as any, null as any, ]); sendApiService.save.mockResolvedValue(undefined as any); diff --git a/apps/cli/src/tools/send/commands/edit.command.ts b/apps/cli/src/tools/send/commands/edit.command.ts index 1d49e0ae6c2..e06cbab39b4 100644 --- a/apps/cli/src/tools/send/commands/edit.command.ts +++ b/apps/cli/src/tools/send/commands/edit.command.ts @@ -107,12 +107,6 @@ export class SendEditCommand { try { const [encSend, encFileData] = await this.sendService.encrypt(sendView, null, req.password); - // Add dates from template - encSend.deletionDate = sendView.deletionDate; - encSend.expirationDate = sendView.expirationDate; - encSend.emails = req.emails && req.emails.join(","); - encSend.authType = req.authType; - await this.sendApiService.save([encSend, encFileData]); } catch (e) { return Response.error(e); diff --git a/libs/common/src/tools/send/models/view/send.view.ts b/libs/common/src/tools/send/models/view/send.view.ts index baa2fe9f73b..4dfa21db95f 100644 --- a/libs/common/src/tools/send/models/view/send.view.ts +++ b/libs/common/src/tools/send/models/view/send.view.ts @@ -48,12 +48,7 @@ export class SendView implements View { this.password = s.password; this.hideEmail = s.hideEmail; this.authType = s.authType; - this.emails = s.emails - ? s.emails - .split(",") - .map((e) => e.trim()) - .filter((e) => e) - : []; + this.emails = s.emails ? s.emails.split(",").map((e) => e.trim()) : []; } get urlB64Key(): string { diff --git a/libs/common/src/tools/send/services/send.service.ts b/libs/common/src/tools/send/services/send.service.ts index 810dbc05a2f..4d244579271 100644 --- a/libs/common/src/tools/send/services/send.service.ts +++ b/libs/common/src/tools/send/services/send.service.ts @@ -127,6 +127,8 @@ export class SendService implements InternalSendServiceAbstraction { } } + send.authType = model.authType; + return [send, fileData]; } From f31aca29289a0e4a874fa6a845266d3e09def830 Mon Sep 17 00:00:00 2001 From: Mike Amirault Date: Fri, 9 Jan 2026 11:02:03 -0500 Subject: [PATCH 08/16] [PM-21774] Adjust icon and tooltip for protected Sends on the Sends list page --- apps/web/src/locales/en/messages.json | 3 +++ libs/common/src/tools/send/enums/auth-type.ts | 12 ++++++++++++ libs/common/src/tools/send/models/data/send.data.ts | 3 +++ .../common/src/tools/send/models/domain/send.spec.ts | 6 ++++++ libs/common/src/tools/send/models/domain/send.ts | 3 +++ .../src/tools/send/models/response/send.response.ts | 3 +++ libs/common/src/tools/send/models/view/send.view.ts | 3 +++ .../send-ui/src/send-table/send-table.component.html | 10 ++++++---- .../send-ui/src/send-table/send-table.component.ts | 2 ++ 9 files changed, 41 insertions(+), 4 deletions(-) create mode 100644 libs/common/src/tools/send/enums/auth-type.ts diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 8024de21e56..c3d524538de 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -12580,5 +12580,8 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/libs/common/src/tools/send/enums/auth-type.ts b/libs/common/src/tools/send/enums/auth-type.ts new file mode 100644 index 00000000000..5d0243249fd --- /dev/null +++ b/libs/common/src/tools/send/enums/auth-type.ts @@ -0,0 +1,12 @@ +/** An type of auth necessary to access a Send */ +export const AuthType = Object.freeze({ + /** Send requires email OTP verification */ + Email: 0, + /** Send requires a password */ + Password: 1, + /** Send requires no auth */ + None: 2, +} as const); + +/** An type of auth necessary to access a Send */ +export type AuthType = (typeof AuthType)[keyof typeof AuthType]; diff --git a/libs/common/src/tools/send/models/data/send.data.ts b/libs/common/src/tools/send/models/data/send.data.ts index 2c6377de0c9..2c6c8509600 100644 --- a/libs/common/src/tools/send/models/data/send.data.ts +++ b/libs/common/src/tools/send/models/data/send.data.ts @@ -1,5 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { AuthType } from "../../enums/auth-type"; import { SendType } from "../../enums/send-type"; import { SendResponse } from "../response/send.response"; @@ -10,6 +11,7 @@ export class SendData { id: string; accessId: string; type: SendType; + authType: AuthType; name: string; notes: string; file: SendFileData; @@ -33,6 +35,7 @@ export class SendData { this.id = response.id; this.accessId = response.accessId; this.type = response.type; + this.authType = response.authType; this.name = response.name; this.notes = response.notes; this.key = response.key; diff --git a/libs/common/src/tools/send/models/domain/send.spec.ts b/libs/common/src/tools/send/models/domain/send.spec.ts index dc9ca7d3444..52c764672bb 100644 --- a/libs/common/src/tools/send/models/domain/send.spec.ts +++ b/libs/common/src/tools/send/models/domain/send.spec.ts @@ -11,6 +11,7 @@ import { EncryptService } from "../../../../key-management/crypto/abstractions/e import { SymmetricCryptoKey } from "../../../../platform/models/domain/symmetric-crypto-key"; import { ContainerService } from "../../../../platform/services/container.service"; import { UserKey } from "../../../../types/key"; +import { AuthType } from "../../enums/auth-type"; import { SendType } from "../../enums/send-type"; import { SendData } from "../data/send.data"; @@ -25,6 +26,7 @@ describe("Send", () => { id: "id", accessId: "accessId", type: SendType.Text, + authType: AuthType.None, name: "encName", notes: "encNotes", text: { @@ -55,6 +57,7 @@ describe("Send", () => { id: null, accessId: null, type: undefined, + authType: undefined, name: null, notes: null, text: undefined, @@ -78,6 +81,7 @@ describe("Send", () => { id: "id", accessId: "accessId", type: SendType.Text, + authType: AuthType.None, name: { encryptedString: "encName", encryptionType: 0 }, notes: { encryptedString: "encNotes", encryptionType: 0 }, text: { @@ -107,6 +111,7 @@ describe("Send", () => { send.id = "id"; send.accessId = "accessId"; send.type = SendType.Text; + send.authType = AuthType.None; send.name = mockEnc("name"); send.notes = mockEnc("notes"); send.text = text; @@ -145,6 +150,7 @@ describe("Send", () => { name: "name", notes: "notes", type: 0, + authType: 2, key: expect.anything(), cryptoKey: "cryptoKey", file: expect.anything(), diff --git a/libs/common/src/tools/send/models/domain/send.ts b/libs/common/src/tools/send/models/domain/send.ts index 2bf16de8a44..9bd0933e59d 100644 --- a/libs/common/src/tools/send/models/domain/send.ts +++ b/libs/common/src/tools/send/models/domain/send.ts @@ -8,6 +8,7 @@ import { UserId } from "@bitwarden/common/types/guid"; import { EncString } from "../../../../key-management/crypto/models/enc-string"; import { Utils } from "../../../../platform/misc/utils"; import Domain from "../../../../platform/models/domain/domain-base"; +import { AuthType } from "../../enums/auth-type"; import { SendType } from "../../enums/send-type"; import { SendData } from "../data/send.data"; import { SendView } from "../view/send.view"; @@ -19,6 +20,7 @@ export class Send extends Domain { id: string; accessId: string; type: SendType; + authType: AuthType; name: EncString; notes: EncString; file: SendFile; @@ -54,6 +56,7 @@ export class Send extends Domain { ); this.type = obj.type; + this.authType = obj.authType; this.maxAccessCount = obj.maxAccessCount; this.accessCount = obj.accessCount; this.password = obj.password; diff --git a/libs/common/src/tools/send/models/response/send.response.ts b/libs/common/src/tools/send/models/response/send.response.ts index 5c6bd4dc1a6..369f17a33c5 100644 --- a/libs/common/src/tools/send/models/response/send.response.ts +++ b/libs/common/src/tools/send/models/response/send.response.ts @@ -1,6 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { BaseResponse } from "../../../../models/response/base.response"; +import { AuthType } from "../../enums/auth-type"; import { SendType } from "../../enums/send-type"; import { SendFileApi } from "../api/send-file.api"; import { SendTextApi } from "../api/send-text.api"; @@ -9,6 +10,7 @@ export class SendResponse extends BaseResponse { id: string; accessId: string; type: SendType; + authType: AuthType; name: string; notes: string; file: SendFileApi; @@ -29,6 +31,7 @@ export class SendResponse extends BaseResponse { this.id = this.getResponseProperty("Id"); this.accessId = this.getResponseProperty("AccessId"); this.type = this.getResponseProperty("Type"); + this.authType = this.getResponseProperty("AuthType"); this.name = this.getResponseProperty("Name"); this.notes = this.getResponseProperty("Notes"); this.key = this.getResponseProperty("Key"); diff --git a/libs/common/src/tools/send/models/view/send.view.ts b/libs/common/src/tools/send/models/view/send.view.ts index 54657b12438..dabc038fee0 100644 --- a/libs/common/src/tools/send/models/view/send.view.ts +++ b/libs/common/src/tools/send/models/view/send.view.ts @@ -4,6 +4,7 @@ import { View } from "../../../../models/view/view"; import { Utils } from "../../../../platform/misc/utils"; import { SymmetricCryptoKey } from "../../../../platform/models/domain/symmetric-crypto-key"; import { DeepJsonify } from "../../../../types/deep-jsonify"; +import { AuthType } from "../../enums/auth-type"; import { SendType } from "../../enums/send-type"; import { Send } from "../domain/send"; @@ -18,6 +19,7 @@ export class SendView implements View { key: Uint8Array; cryptoKey: SymmetricCryptoKey; type: SendType = null; + authType: AuthType = null; text = new SendTextView(); file = new SendFileView(); maxAccessCount?: number = null; @@ -38,6 +40,7 @@ export class SendView implements View { this.id = s.id; this.accessId = s.accessId; this.type = s.type; + this.authType = s.authType; this.maxAccessCount = s.maxAccessCount; this.accessCount = s.accessCount; this.revisionDate = s.revisionDate; diff --git a/libs/tools/send/send-ui/src/send-table/send-table.component.html b/libs/tools/send/send-ui/src/send-table/send-table.component.html index 96b9519019e..cc2fca2c41c 100644 --- a/libs/tools/send/send-ui/src/send-table/send-table.component.html +++ b/libs/tools/send/send-ui/src/send-table/send-table.component.html @@ -33,14 +33,16 @@ > {{ "disabled" | i18n }} } - @if (s.password) { + @if (s.authType !== authType.None) { + @let titleKey = + s.authType === authType.Email ? "emailProtected" : "passwordProtected"; - {{ "password" | i18n }} + {{ titleKey | i18n }} } @if (s.maxAccessCountReached) { Date: Fri, 9 Jan 2026 12:08:51 -0500 Subject: [PATCH 09/16] [PM-21774] Update Sent table UI stories --- .../send-table/send-table.component.stories.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/libs/tools/send/send-ui/src/send-table/send-table.component.stories.ts b/libs/tools/send/send-ui/src/send-table/send-table.component.stories.ts index d2d630b69a2..3f1a782de70 100644 --- a/libs/tools/send/send-ui/src/send-table/send-table.component.stories.ts +++ b/libs/tools/send/send-ui/src/send-table/send-table.component.stories.ts @@ -1,6 +1,7 @@ import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { AuthType } from "@bitwarden/common/tools/send/enums/auth-type"; import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { TableDataSource, I18nMockService } from "@bitwarden/components"; @@ -13,6 +14,7 @@ function createMockSend(id: number, overrides: Partial = {}): SendView send.id = `send-${id}`; send.name = "My Send"; send.type = SendType.Text; + send.authType = AuthType.None; send.deletionDate = new Date("2030-01-01T12:00:00Z"); send.password = null as any; @@ -34,19 +36,26 @@ dataSource.data = [ createMockSend(2, { name: "Password Protected Send", type: SendType.Text, + authType: AuthType.Password, password: "123", }), createMockSend(3, { + name: "Email Protected Send", + type: SendType.Text, + authType: AuthType.Email, + emails: ["ckent@dailyplanet.com"], + }), + createMockSend(4, { name: "Disabled Send", type: SendType.Text, disabled: true, }), - createMockSend(4, { + createMockSend(5, { name: "Expired Send", type: SendType.File, expirationDate: new Date("2025-12-01T00:00:00Z"), }), - createMockSend(5, { + createMockSend(6, { name: "Max Access Reached", type: SendType.Text, maxAccessCount: 5, @@ -69,7 +78,8 @@ export default { deletionDate: "Deletion Date", options: "Options", disabled: "Disabled", - password: "Password", + passwordProtected: "Password protected", + emailProtected: "Email protected", maxAccessCountReached: "Max access count reached", expired: "Expired", pendingDeletion: "Pending deletion", From 06bc0d5d01e6dee5903bbb0854618e87762aff4f Mon Sep 17 00:00:00 2001 From: Mike Amirault Date: Fri, 9 Jan 2026 13:25:17 -0500 Subject: [PATCH 10/16] [PM-21774] Fix Send table UI story --- .../send/send-ui/src/send-table/send-table.component.stories.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/libs/tools/send/send-ui/src/send-table/send-table.component.stories.ts b/libs/tools/send/send-ui/src/send-table/send-table.component.stories.ts index 3f1a782de70..f3b73600cc8 100644 --- a/libs/tools/send/send-ui/src/send-table/send-table.component.stories.ts +++ b/libs/tools/send/send-ui/src/send-table/send-table.component.stories.ts @@ -58,6 +58,7 @@ dataSource.data = [ createMockSend(6, { name: "Max Access Reached", type: SendType.Text, + authType: AuthType.Password, maxAccessCount: 5, accessCount: 5, password: "123", From ff81b34b28262c69f4e001cf8a37430f61bbded2 Mon Sep 17 00:00:00 2001 From: John Harrington <84741727+harr1424@users.noreply.github.com> Date: Thu, 15 Jan 2026 08:41:14 -0700 Subject: [PATCH 11/16] fix eslint issue --- apps/cli/src/tools/send/commands/create.command.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/cli/src/tools/send/commands/create.command.ts b/apps/cli/src/tools/send/commands/create.command.ts index bd83c2d6806..ad4ff9c4e18 100644 --- a/apps/cli/src/tools/send/commands/create.command.ts +++ b/apps/cli/src/tools/send/commands/create.command.ts @@ -19,7 +19,6 @@ import { Response } from "../../../models/response"; import { CliUtils } from "../../../utils"; import { SendTextResponse } from "../models/send-text.response"; import { SendResponse } from "../models/send.response"; -`` export class SendCreateCommand { constructor( private sendService: SendService, From c8c7f8b83280951380f20a6785f64f4c75e2663c Mon Sep 17 00:00:00 2001 From: John Harrington <84741727+harr1424@users.noreply.github.com> Date: Thu, 15 Jan 2026 09:26:43 -0700 Subject: [PATCH 12/16] resolve eslint errors --- apps/cli/src/tools/send/commands/create.command.spec.ts | 4 ++-- apps/cli/src/tools/send/commands/edit.command.spec.ts | 4 ++-- apps/cli/src/tools/send/commands/edit.command.ts | 2 +- apps/cli/src/tools/send/models/send.response.ts | 2 +- libs/common/src/tools/send/models/response/send.response.ts | 6 ++---- 5 files changed, 8 insertions(+), 10 deletions(-) diff --git a/apps/cli/src/tools/send/commands/create.command.spec.ts b/apps/cli/src/tools/send/commands/create.command.spec.ts index f53c71dc345..d3702689812 100644 --- a/apps/cli/src/tools/send/commands/create.command.spec.ts +++ b/apps/cli/src/tools/send/commands/create.command.spec.ts @@ -7,10 +7,10 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { mockAccountInfoWith } from "@bitwarden/common/spec"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; -import { AuthType } from "@bitwarden/common/tools/send/models/domain/send"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { AuthType } from "@bitwarden/common/tools/send/types/auth-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { UserId } from "@bitwarden/user-core"; import { SendCreateCommand } from "./create.command"; diff --git a/apps/cli/src/tools/send/commands/edit.command.spec.ts b/apps/cli/src/tools/send/commands/edit.command.spec.ts index b1d57517641..5bac63d3821 100644 --- a/apps/cli/src/tools/send/commands/edit.command.spec.ts +++ b/apps/cli/src/tools/send/commands/edit.command.spec.ts @@ -6,11 +6,11 @@ import { of } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { mockAccountInfoWith } from "@bitwarden/common/spec"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; -import { AuthType } from "@bitwarden/common/tools/send/models/domain/send"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { AuthType } from "@bitwarden/common/tools/send/types/auth-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { UserId } from "@bitwarden/user-core"; import { Response } from "../../../models/response"; diff --git a/apps/cli/src/tools/send/commands/edit.command.ts b/apps/cli/src/tools/send/commands/edit.command.ts index a9e9f8ab604..32d0cff31f4 100644 --- a/apps/cli/src/tools/send/commands/edit.command.ts +++ b/apps/cli/src/tools/send/commands/edit.command.ts @@ -3,11 +3,11 @@ import { firstValueFrom } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { AuthType } from "@bitwarden/common/tools/send/models/domain/send"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { AuthType } from "@bitwarden/common/tools/send/types/auth-type"; import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { Response } from "../../../models/response"; diff --git a/apps/cli/src/tools/send/models/send.response.ts b/apps/cli/src/tools/send/models/send.response.ts index 44664f4ad3a..c8182cbfaf8 100644 --- a/apps/cli/src/tools/send/models/send.response.ts +++ b/apps/cli/src/tools/send/models/send.response.ts @@ -1,8 +1,8 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { AuthType } from "@bitwarden/common/tools/send/models/domain/send"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { AuthType } from "@bitwarden/common/tools/send/types/auth-type"; import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { BaseResponse } from "../../../models/response/base.response"; diff --git a/libs/common/src/tools/send/models/response/send.response.ts b/libs/common/src/tools/send/models/response/send.response.ts index e73a0ecbbd6..a51b1e8ac7a 100644 --- a/libs/common/src/tools/send/models/response/send.response.ts +++ b/libs/common/src/tools/send/models/response/send.response.ts @@ -1,10 +1,9 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { AuthType } from "@bitwarden/common/tools/send/models/domain/send"; +import { AuthType } from "@bitwarden/common/tools/send/types/auth-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { BaseResponse } from "../../../../models/response/base.response"; -import { AuthType } from "../../types/auth-type"; -import { SendType } from "../../types/send-type"; import { SendFileApi } from "../api/send-file.api"; import { SendTextApi } from "../api/send-text.api"; @@ -12,7 +11,6 @@ export class SendResponse extends BaseResponse { id: string; accessId: string; type: SendType; - authType: AuthType; name: string; notes: string; file: SendFileApi; From d27a9b434c9e9bc54b1a05b3d451d5388bf31192 Mon Sep 17 00:00:00 2001 From: John Harrington <84741727+harr1424@users.noreply.github.com> Date: Thu, 15 Jan 2026 09:58:34 -0700 Subject: [PATCH 13/16] fix strict typing errors --- libs/common/src/tools/send/models/domain/send.spec.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/libs/common/src/tools/send/models/domain/send.spec.ts b/libs/common/src/tools/send/models/domain/send.spec.ts index 691876a7e31..94f61acdb46 100644 --- a/libs/common/src/tools/send/models/domain/send.spec.ts +++ b/libs/common/src/tools/send/models/domain/send.spec.ts @@ -1,6 +1,7 @@ import { mock } from "jest-mock-extended"; import { of } from "rxjs"; +import { Send } from "@bitwarden/common/tools/send/models/domain/send"; import { emptyGuid, UserId } from "@bitwarden/common/types/guid"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports @@ -15,7 +16,6 @@ import { AuthType } from "../../types/auth-type"; import { SendType } from "../../types/send-type"; import { SendData } from "../data/send.data"; -import { AuthType, Send } from "./send"; import { SendText } from "./send-text"; describe("Send", () => { @@ -26,7 +26,6 @@ describe("Send", () => { id: "id", accessId: "accessId", type: SendType.Text, - authType: AuthType.None, name: "encName", notes: "encNotes", text: { @@ -82,7 +81,6 @@ describe("Send", () => { id: "id", accessId: "accessId", type: SendType.Text, - authType: AuthType.None, name: { encryptedString: "encName", encryptionType: 0 }, notes: { encryptedString: "encNotes", encryptionType: 0 }, text: { @@ -153,7 +151,6 @@ describe("Send", () => { name: "name", notes: "notes", type: 0, - authType: 2, key: expect.anything(), cryptoKey: "cryptoKey", file: expect.anything(), From 347784d9902e12fc3bd8fbcff369f8f458662bb1 Mon Sep 17 00:00:00 2001 From: John Harrington <84741727+harr1424@users.noreply.github.com> Date: Thu, 15 Jan 2026 17:54:59 -0700 Subject: [PATCH 14/16] gate usage of send email field in create and edit behind SendEmailOTP feat flag --- apps/cli/src/tools/send/send.program.ts | 35 +++++++++++++++++-- .../src/tools/send/models/data/send.data.ts | 1 - .../src/tools/send/models/domain/send.spec.ts | 3 -- .../src/tools/send/models/domain/send.ts | 1 - .../send/models/response/send.response.ts | 3 -- .../src/tools/send/models/view/send.view.ts | 1 - 6 files changed, 33 insertions(+), 11 deletions(-) diff --git a/apps/cli/src/tools/send/send.program.ts b/apps/cli/src/tools/send/send.program.ts index f6b75be4b12..9c5e077afd9 100644 --- a/apps/cli/src/tools/send/send.program.ts +++ b/apps/cli/src/tools/send/send.program.ts @@ -6,6 +6,7 @@ import * as path from "path"; import * as chalk from "chalk"; import { program, Command, Option, OptionValues } from "commander"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SendType } from "@bitwarden/common/tools/send/types/send-type"; @@ -81,6 +82,16 @@ export class SendProgram extends BaseProgram { .addCommand(this.removePasswordCommand()) .addCommand(this.deleteCommand()) .action(async (data: string, options: OptionValues) => { + if (options.email) { + const emailFeatureEnabled = await this.serviceContainer.configService.getFeatureFlag( + FeatureFlag.SendEmailOTP, + ); + if (!emailFeatureEnabled) { + this.processResponse(Response.error("The --email feature is not currently available.")); + return; + } + } + const encodedJson = this.makeSendJson(data, options); let response: Response; @@ -213,6 +224,17 @@ export class SendProgram extends BaseProgram { .action(async (encodedJson: string, options: OptionValues, args: { parent: Command }) => { // subcommands inherit flags from their parent; they cannot override them const { fullObject = false, email = undefined, password = undefined } = args.parent.opts(); + + if (email) { + const emailFeatureEnabled = await this.serviceContainer.configService.getFeatureFlag( + FeatureFlag.SendEmailOTP, + ); + if (!emailFeatureEnabled) { + this.processResponse(Response.error("The --email feature is not currently available.")); + return; + } + } + const mergedOptions = { ...options, fullObject: fullObject, @@ -241,6 +263,17 @@ export class SendProgram extends BaseProgram { }) .action(async (encodedJson: string, options: OptionValues, args: { parent: Command }) => { await this.exitIfLocked(); + const { email = undefined, password = undefined } = args.parent.opts(); + if (email) { + const emailFeatureEnabled = await this.serviceContainer.configService.getFeatureFlag( + FeatureFlag.SendEmailOTP, + ); + if (!emailFeatureEnabled) { + this.processResponse(Response.error("The --email feature is not currently available.")); + return; + } + } + const getCmd = new SendGetCommand( this.serviceContainer.sendService, this.serviceContainer.environmentService, @@ -257,8 +290,6 @@ export class SendProgram extends BaseProgram { this.serviceContainer.accountService, ); - // subcommands inherit flags from their parent; they cannot override them - const { email = undefined, password = undefined } = args.parent.opts(); const mergedOptions = { ...options, email, diff --git a/libs/common/src/tools/send/models/data/send.data.ts b/libs/common/src/tools/send/models/data/send.data.ts index 47b43a3f99b..b4317c48959 100644 --- a/libs/common/src/tools/send/models/data/send.data.ts +++ b/libs/common/src/tools/send/models/data/send.data.ts @@ -11,7 +11,6 @@ export class SendData { id: string; accessId: string; type: SendType; - authType: AuthType; name: string; notes: string; file: SendFileData; diff --git a/libs/common/src/tools/send/models/domain/send.spec.ts b/libs/common/src/tools/send/models/domain/send.spec.ts index f9620cc2837..94f61acdb46 100644 --- a/libs/common/src/tools/send/models/domain/send.spec.ts +++ b/libs/common/src/tools/send/models/domain/send.spec.ts @@ -26,7 +26,6 @@ describe("Send", () => { id: "id", accessId: "accessId", type: SendType.Text, - authType: AuthType.None, name: "encName", notes: "encNotes", text: { @@ -82,7 +81,6 @@ describe("Send", () => { id: "id", accessId: "accessId", type: SendType.Text, - authType: AuthType.None, name: { encryptedString: "encName", encryptionType: 0 }, notes: { encryptedString: "encNotes", encryptionType: 0 }, text: { @@ -153,7 +151,6 @@ describe("Send", () => { name: "name", notes: "notes", type: 0, - authType: 2, key: expect.anything(), cryptoKey: "cryptoKey", file: expect.anything(), diff --git a/libs/common/src/tools/send/models/domain/send.ts b/libs/common/src/tools/send/models/domain/send.ts index cb117174e2a..ab112dfc4ad 100644 --- a/libs/common/src/tools/send/models/domain/send.ts +++ b/libs/common/src/tools/send/models/domain/send.ts @@ -20,7 +20,6 @@ export class Send extends Domain { id: string; accessId: string; type: SendType; - authType: AuthType; name: EncString; notes: EncString; file: SendFile; diff --git a/libs/common/src/tools/send/models/response/send.response.ts b/libs/common/src/tools/send/models/response/send.response.ts index e1822f39519..a51b1e8ac7a 100644 --- a/libs/common/src/tools/send/models/response/send.response.ts +++ b/libs/common/src/tools/send/models/response/send.response.ts @@ -4,8 +4,6 @@ import { AuthType } from "@bitwarden/common/tools/send/types/auth-type"; import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { BaseResponse } from "../../../../models/response/base.response"; -import { AuthType } from "../../types/auth-type"; -import { SendType } from "../../types/send-type"; import { SendFileApi } from "../api/send-file.api"; import { SendTextApi } from "../api/send-text.api"; @@ -13,7 +11,6 @@ export class SendResponse extends BaseResponse { id: string; accessId: string; type: SendType; - authType: AuthType; name: string; notes: string; file: SendFileApi; diff --git a/libs/common/src/tools/send/models/view/send.view.ts b/libs/common/src/tools/send/models/view/send.view.ts index ee5ac962133..ac6f5943a09 100644 --- a/libs/common/src/tools/send/models/view/send.view.ts +++ b/libs/common/src/tools/send/models/view/send.view.ts @@ -19,7 +19,6 @@ export class SendView implements View { key: Uint8Array; cryptoKey: SymmetricCryptoKey; type: SendType = null; - authType: AuthType = null; text = new SendTextView(); file = new SendFileView(); maxAccessCount?: number = null; From aa241bf8261306d17f19e28eac81892758fe017f Mon Sep 17 00:00:00 2001 From: John Harrington <84741727+harr1424@users.noreply.github.com> Date: Fri, 16 Jan 2026 10:26:58 -0700 Subject: [PATCH 15/16] re-implement feature flag using existing pattern --- apps/cli/src/register-oss-programs.ts | 2 +- apps/cli/src/tools/send/send.program.ts | 32 ++++++++++--------------- 2 files changed, 14 insertions(+), 20 deletions(-) diff --git a/apps/cli/src/register-oss-programs.ts b/apps/cli/src/register-oss-programs.ts index 71d7aaa0d52..f0b0475c808 100644 --- a/apps/cli/src/register-oss-programs.ts +++ b/apps/cli/src/register-oss-programs.ts @@ -18,5 +18,5 @@ export async function registerOssPrograms(serviceContainer: ServiceContainer) { await vaultProgram.register(); const sendProgram = new SendProgram(serviceContainer); - sendProgram.register(); + await sendProgram.register(); } diff --git a/apps/cli/src/tools/send/send.program.ts b/apps/cli/src/tools/send/send.program.ts index 9c5e077afd9..a84b6c15ead 100644 --- a/apps/cli/src/tools/send/send.program.ts +++ b/apps/cli/src/tools/send/send.program.ts @@ -32,13 +32,16 @@ import { parseEmail } from "./util"; const writeLn = CliUtils.writeLn; export class SendProgram extends BaseProgram { - register() { - program.addCommand(this.sendCommand()); + async register() { + const emailAuthEnabled = await this.serviceContainer.configService.getFeatureFlag( + FeatureFlag.SendEmailOTP, + ); + program.addCommand(this.sendCommand(emailAuthEnabled)); // receive is accessible both at `bw receive` and `bw send receive` program.addCommand(this.receiveCommand()); } - private sendCommand(): Command { + private sendCommand(emailAuthEnabled: boolean): Command { return new Command("send") .argument("", "The data to Send. Specify as a filepath with the --file option") .description( @@ -77,16 +80,13 @@ export class SendProgram extends BaseProgram { .addCommand(this.templateCommand()) .addCommand(this.getCommand()) .addCommand(this.receiveCommand()) - .addCommand(this.createCommand()) - .addCommand(this.editCommand()) + .addCommand(this.createCommand(emailAuthEnabled)) + .addCommand(this.editCommand(emailAuthEnabled)) .addCommand(this.removePasswordCommand()) .addCommand(this.deleteCommand()) .action(async (data: string, options: OptionValues) => { if (options.email) { - const emailFeatureEnabled = await this.serviceContainer.configService.getFeatureFlag( - FeatureFlag.SendEmailOTP, - ); - if (!emailFeatureEnabled) { + if (!emailAuthEnabled) { this.processResponse(Response.error("The --email feature is not currently available.")); return; } @@ -208,7 +208,7 @@ export class SendProgram extends BaseProgram { }); } - private createCommand(): Command { + private createCommand(emailAuthEnabled: any): Command { return new Command("create") .argument("[encodedJson]", "JSON object to upload. Can also be piped in through stdin.") .description("create a Send") @@ -226,10 +226,7 @@ export class SendProgram extends BaseProgram { const { fullObject = false, email = undefined, password = undefined } = args.parent.opts(); if (email) { - const emailFeatureEnabled = await this.serviceContainer.configService.getFeatureFlag( - FeatureFlag.SendEmailOTP, - ); - if (!emailFeatureEnabled) { + if (!emailAuthEnabled) { this.processResponse(Response.error("The --email feature is not currently available.")); return; } @@ -247,7 +244,7 @@ export class SendProgram extends BaseProgram { }); } - private editCommand(): Command { + private editCommand(emailAuthEnabled: any): Command { return new Command("edit") .argument( "[encodedJson]", @@ -265,10 +262,7 @@ export class SendProgram extends BaseProgram { await this.exitIfLocked(); const { email = undefined, password = undefined } = args.parent.opts(); if (email) { - const emailFeatureEnabled = await this.serviceContainer.configService.getFeatureFlag( - FeatureFlag.SendEmailOTP, - ); - if (!emailFeatureEnabled) { + if (!emailAuthEnabled) { this.processResponse(Response.error("The --email feature is not currently available.")); return; } From 86f1c6f6526f8a19cc79a678c24a64740f6f2ec7 Mon Sep 17 00:00:00 2001 From: John Harrington <84741727+harr1424@users.noreply.github.com> Date: Fri, 16 Jan 2026 10:53:43 -0700 Subject: [PATCH 16/16] trim password whitespace in edit command --- apps/cli/src/tools/send/commands/edit.command.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cli/src/tools/send/commands/edit.command.ts b/apps/cli/src/tools/send/commands/edit.command.ts index 32d0cff31f4..0709a33b88f 100644 --- a/apps/cli/src/tools/send/commands/edit.command.ts +++ b/apps/cli/src/tools/send/commands/edit.command.ts @@ -64,7 +64,7 @@ export class SendEditCommand { // Infer authType based on emails/password (mutually exclusive) const hasEmails = req.emails != null && req.emails.length > 0; - const hasPassword = req.password != null && req.password !== ""; + const hasPassword = req.password != null && req.password.trim() !== ""; if (hasEmails && hasPassword) { return Response.badRequest("--password and --emails are mutually exclusive.");