commit da4eb33ae54c586079d8b71549f89ced347e16f7 parent 7401814819447117ce332558ea5ac9dd6d3d0b7d Author: Martin Schanzenbach <schanzen@gnunet.org> Date: Thu, 30 Oct 2025 23:21:08 +0100 Add mailbox UI elements and HTTP client Diffstat:
18 files changed, 1018 insertions(+), 33 deletions(-)
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-mailbox-basic.ts b/packages/taler-harness/src/integrationtests/test-wallet-mailbox-basic.ts @@ -0,0 +1,82 @@ +/* + This file is part of GNU Taler + (C) 2025 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * Imports. + */ +import { MailboxMessageMoneyTransfer, MailboxMessagePaymentRequestInvoice, MailboxMessageType } from "@gnu-taler/taler-util"; +import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import { GlobalTestState } from "../harness/harness.js"; +import { + createSimpleTestkudosEnvironmentV3, +} from "../harness/environments.js"; + +/** + * Run test for basic mailbox operations. + */ +export async function runWalletMailboxBasicTest(t: GlobalTestState) { + // Set up test environment + + const { commonDb, merchant, walletClient, bankClient, exchange } = + await createSimpleTestkudosEnvironmentV3(t); + const mailboxBaseUrl = "https://example.com"; + const mbConf = await walletClient.call(WalletApiOperation.GetMailbox, mailboxBaseUrl); + + const mbConfAgain = await walletClient.call(WalletApiOperation.GetMailbox, mailboxBaseUrl); + t.assertDeepEqual(mbConf, mbConfAgain); + const messageBob: MailboxMessageMoneyTransfer = { + originMailboxBaseUrl: mailboxBaseUrl, + type: MailboxMessageType.MoneyTransfer, + id: "0", + senderHint: "bobby", + payPushUri: "taler://pay-push/XQXA", + bodyRaw: "ABCD", + }; + const messageAlice: MailboxMessagePaymentRequestInvoice = { + originMailboxBaseUrl: mailboxBaseUrl, + type: MailboxMessageType.PaymentRequestInvoice, + id: "1", + senderHint: "jack", + payPullUri: "taler://pay-pull/XQXA", + bodyRaw: "EFGH", + }; + await walletClient.call(WalletApiOperation.AddMailboxMessage, { + message: messageBob, + }); + await walletClient.call(WalletApiOperation.AddMailboxMessage, { + message: messageAlice, + }); + + + { + const bi = await walletClient.call(WalletApiOperation.GetMailboxMessages, {}); + t.assertDeepEqual(bi.messages.length, 2); + } + + await walletClient.call(WalletApiOperation.DeleteMailboxMessage, { + message: messageBob, + }); + + { + const bi = await walletClient.call(WalletApiOperation.GetMailboxMessages, {}); + t.assertDeepEqual(bi.messages.length, 1); + t.assertDeepEqual(bi.messages[0], messageAlice); + } + +} + +runWalletMailboxBasicTest.suites = ["wallet", "wallet-mailbox"]; +runWalletMailboxBasicTest.experimental = true; diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts @@ -180,6 +180,7 @@ import { runWalletExchangeMigrationTest } from "./test-wallet-exchange-migration import { runWalletExchangeUpdateTest } from "./test-wallet-exchange-update.js"; import { runWalletGenDbTest } from "./test-wallet-gendb.js"; import { runWalletInsufficientBalanceTest } from "./test-wallet-insufficient-balance.js"; +import { runWalletMailboxBasicTest } from "./test-wallet-mailbox-basic.js"; import { runWalletNetworkAvailabilityTest } from "./test-wallet-network-availability.js"; import { runWalletNotificationsTest } from "./test-wallet-notifications.js"; import { runWalletObservabilityTest } from "./test-wallet-observability.js"; @@ -275,6 +276,7 @@ const allTests: TestMainFunction[] = [ runWalletNotificationsTest, runWalletCryptoWorkerTest, runWalletContactsBasicTest, + runWalletMailboxBasicTest, runWalletDblessTest, runWallettestingTest, runWithdrawalAbortBankTest, diff --git a/packages/taler-util/src/http-client/mailbox.ts b/packages/taler-util/src/http-client/mailbox.ts @@ -0,0 +1,210 @@ +/* + This file is part of GNU Taler + (C) 2025 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { + CancellationToken, + FailCasesByMethod, + HttpStatusCode, + LibtoolVersion, + OperationFail, + OperationOk, + ResultByMethod, + TalerMailboxApi, + MailboxMessageDeletionRequest, + carefullyParseConfig, + codecForTalerMailboxConfigResponse, + codecForTalerMailboxRateLimitedResponse, + opEmptySuccess, + opFixedSuccess, + opKnownAlternativeHttpFailure, + opKnownHttpFailure, + opUnknownHttpFailure +} from "@gnu-taler/taler-util"; +import { + HttpRequestLibrary, + createPlatformHttpLib, +} from "@gnu-taler/taler-util/http"; + +export type TalerMailboxInstanceResultByMethod< + prop extends keyof TalerMailboxInstanceHttpClient, +> = ResultByMethod<TalerMailboxInstanceHttpClient, prop>; +export type TalerMailboxInstanceErrorsByMethod< + prop extends keyof TalerMailboxInstanceHttpClient, +> = FailCasesByMethod<TalerMailboxInstanceHttpClient, prop>; + +/** + * Protocol version spoken with the service. + * + * Endpoint must be ordered in the same way that in the docs + * Response code (http and taler) must have the same order that in the docs + * That way is easier to see changes + * + * Uses libtool's current:revision:age versioning. + */ +export class TalerMailboxInstanceHttpClient { + public static readonly PROTOCOL_VERSION = "1:0:0"; + + readonly httpLib: HttpRequestLibrary; + readonly cancellationToken: CancellationToken | undefined; + + constructor( + readonly baseUrl: string, + httpClient?: HttpRequestLibrary, + cancellationToken?: CancellationToken, + ) { + this.httpLib = httpClient ?? createPlatformHttpLib(); + this.cancellationToken = cancellationToken; + } + + static isCompatible(version: string): boolean { + const compare = LibtoolVersion.compare( + TalerMailboxInstanceHttpClient.PROTOCOL_VERSION, + version, + ); + return compare?.compatible ?? false; + } + + /** + * https://docs.taler.net/core/api-mailbox.html#get--config + */ + async getConfig() { + const url = new URL(`config`, this.baseUrl); + const resp = await this.httpLib.fetch(url.href, { + method: "GET", + }); + switch (resp.status) { + case HttpStatusCode.Ok: + return carefullyParseConfig( + "taler-mailbox", + TalerMailboxInstanceHttpClient.PROTOCOL_VERSION, + resp, + codecForTalerMailboxConfigResponse(), + ); + case HttpStatusCode.NotFound: + return opKnownHttpFailure(resp.status, resp); + default: + return opUnknownHttpFailure(resp); + } + } + + /** + * https://docs.taler.net/core/api-mailbox.html#post--$H_MAILBOX + */ + async sendMessage(args: { + h_address: string; + body: Uint8Array; + }): Promise< + | OperationOk<void> + | OperationFail<TalerMailboxApi.MailboxRateLimitedResponse> + | OperationFail<HttpStatusCode.PaymentRequired> + | OperationFail<HttpStatusCode.TooManyRequests> + | OperationFail<HttpStatusCode.Forbidden> + > { + const { h_address, body } = args; + const url = new URL(`${h_address}`, this.baseUrl); + + const resp = await this.httpLib.fetch(url.href, { + method: "POST", + body, + cancellationToken: this.cancellationToken, + }); + + switch (resp.status) { + case HttpStatusCode.NoContent: { + return opEmptySuccess(); + } + case HttpStatusCode.PaymentRequired: { + return opKnownHttpFailure(resp.status, resp); + } + case HttpStatusCode.Forbidden: { + return opKnownHttpFailure(resp.status, resp); + } + case HttpStatusCode.TooManyRequests: { + return opKnownAlternativeHttpFailure(resp, resp.status, codecForTalerMailboxRateLimitedResponse()); + } + default: + return opUnknownHttpFailure(resp); + } + } + + /** + * https://docs.taler.net/core/api-mailbox.html#get--$H_MAILBOX + */ + async getMessages(args: { + h_mailbox: string; + }): Promise< + | OperationOk<Uint8Array> + | OperationOk<void> + | OperationFail<HttpStatusCode.TooManyRequests> + >{ + const { h_mailbox } = args; + const url = new URL(`${h_mailbox}`, this.baseUrl); + + const resp = await this.httpLib.fetch(url.href, { + method: "GET", + cancellationToken: this.cancellationToken, + }); + + switch (resp.status) { + case HttpStatusCode.Ok: { + // FIXME how do we decode the byte array? + const buffer = await resp.bytes(); + const uintar = new Uint8Array(buffer); + return opFixedSuccess(uintar); + } + case HttpStatusCode.NoContent: { + return opEmptySuccess(); + } + case HttpStatusCode.TooManyRequests: { + return opKnownAlternativeHttpFailure(resp, resp.status, codecForTalerMailboxRateLimitedResponse()); + } + default: + return opUnknownHttpFailure(resp); + } + } + + /** + * https://docs.taler.net/core/api-mailbox.html#delete--$ADDRESS + */ + async deleteMessages(args: { + mailbox: string; + body: MailboxMessageDeletionRequest; + }): Promise< + | OperationOk<void> + | OperationFail<HttpStatusCode.Forbidden> + | OperationFail<HttpStatusCode.NotFound> + >{ + const { mailbox, body } = args; + const url = new URL(`${mailbox}`, this.baseUrl); + + const resp = await this.httpLib.fetch(url.href, { + method: "DELETE", + body: body, + cancellationToken: this.cancellationToken, + }); + + switch (resp.status) { + case HttpStatusCode.NoContent: { + return opEmptySuccess(); + } + case HttpStatusCode.Forbidden: + case HttpStatusCode.NotFound: + return opKnownHttpFailure(resp.status, resp); + default: + return opUnknownHttpFailure(resp); + } + } +} diff --git a/packages/taler-util/src/index.ts b/packages/taler-util/src/index.ts @@ -20,6 +20,7 @@ export * from "./http-client/challenger.js"; export * from "./http-client/donau-client.js"; export * from "./http-client/exchange-client.js"; export * from "./http-client/merchant.js"; +export * from "./http-client/mailbox.js"; export * from "./http-client/officer-account.js"; export { @@ -65,6 +66,7 @@ export * from "./url.js"; export * from "./types-taler-bank-conversion.js"; export * from "./types-taler-bank-integration.js"; export * from "./types-taler-exchange.js"; +export * from "./types-taler-mailbox.js"; export * from "./types-taler-merchant.js"; export * from "./types-donau.js"; // end @@ -80,6 +82,7 @@ export * as ChallengerApi from "./types-taler-challenger.js"; export * as TalerCorebankApi from "./types-taler-corebank.js"; export * as TalerExchangeApi from "./types-taler-exchange.js"; export * as TalerKycAml from "./types-taler-kyc-aml.js"; +export * as TalerMailboxApi from "./types-taler-mailbox.js"; export * as TalerMerchantApi from "./types-taler-merchant.js"; export * as TalerRevenueApi from "./types-taler-revenue.js"; export * as TalerWireGatewayApi from "./types-taler-wire-gateway.js"; diff --git a/packages/taler-util/src/notifications.ts b/packages/taler-util/src/notifications.ts @@ -27,6 +27,7 @@ import { TransactionState } from "./types-taler-wallet-transactions.js"; import { ContactEntry, ExchangeEntryState, + MailboxMessage, TalerErrorDetail, TransactionIdStr, } from "./types-taler-wallet.js"; @@ -37,6 +38,8 @@ export enum NotificationType { BackupOperationError = "backup-error", ContactAdded = "contact-added", ContactDeleted = "contact-deleted", + MailboxMessageAdded = "mailbox-message-added", + MailboxMessageDeleted = "mailbox-message-deleted", TransactionStateTransition = "transaction-state-transition", ExchangeStateTransition = "exchange-state-transition", Idle = "idle", @@ -144,6 +147,30 @@ export interface ContactDeletedNotification { } /** + * Notification emitted when a mailbox message is added + */ +export interface MailboxMessageAddedNotification { + type: NotificationType.MailboxMessageAdded; + + /** + * The message that was added + */ + message: MailboxMessage; +} + +/** + * Notification emitted when a mailbox message is deleted + */ +export interface MailboxMessageDeletedNotification { + type: NotificationType.MailboxMessageDeleted; + + /** + * The message that was deleted + */ + message: MailboxMessage; +} + +/** * Transaction emitted when a bank account changes. */ export interface BankAccountChangeNotification { @@ -318,6 +345,8 @@ export type WalletNotification = | BackupOperationErrorNotification | ContactAddedNotification | ContactDeletedNotification + | MailboxMessageAddedNotification + | MailboxMessageDeletedNotification | ExchangeStateTransitionNotification | TransactionStateTransitionNotification | TaskProgressNotification diff --git a/packages/taler-util/src/types-taler-common.ts b/packages/taler-util/src/types-taler-common.ts @@ -222,7 +222,7 @@ export const codecForInternationalizedString = export const codecForCurrencySpecificiation = (): Codec<CurrencySpecification> => - buildCodecForObject<CurrencySpecification>() +buildCodecForObject<CurrencySpecification>() .property("name", codecForString()) .property("num_fractional_input_digits", codecForNumber()) .property("num_fractional_normal_digits", codecForNumber()) diff --git a/packages/taler-util/src/types-taler-mailbox.ts b/packages/taler-util/src/types-taler-mailbox.ts @@ -0,0 +1,111 @@ +/* + This file is part of GNU Taler + (C) 2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + + SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { codecForAmountString } from "./amounts.js"; +import { + Codec, + buildCodecForObject, + codecForNumber, +} from "./codec.js"; +import { + codecForString, + codecForConstString, +} from "./index.js"; +import { + codecForDuration, +} from "./time.js"; +import { + AmountString, + EddsaSignatureString, + HashCodeString, + Integer, + RelativeTime, +} from "./types-taler-common.js"; + +export const codecForTalerMailboxConfigResponse = + (): Codec<TalerMailboxConfigResponse> => + buildCodecForObject<TalerMailboxConfigResponse>() + .property("version", codecForString()) + .property("name", codecForConstString("taler-mailbox")) + .property("message_fee", codecForAmountString()) + .property("message_body_bytes", codecForNumber()) + .property("message_response_limit", codecForNumber()) + .property("delivery_period", codecForDuration) + .build("TalerMailboxApi.VersionResponse"); + + +export interface TalerMailboxConfigResponse { + // libtool-style representation of the Mailbox protocol version, see + // https://www.gnu.org/software/libtool/manual/html_node/Versioning.html#Versioning + // The format is "current:revision:age". + version: string; + + // Name of the protocol. + name: "taler-mailbox"; + + // Fee per message. + message_fee: AmountString; + + // Fixed size of message body. + message_body_bytes: Integer; + + // Maximum number of messages in a + // single response. + message_response_limit: Integer; + + // How long will the service store a message + // before giving up on delivery? + delivery_period: RelativeTime; +} + +export const codecForTalerMailboxRateLimitedResponse = + (): Codec<MailboxRateLimitedResponse> => + buildCodecForObject<MailboxRateLimitedResponse>() + .property("code", codecForNumber()) + .property("retry_delay", codecForDuration) + .property("hint", codecForString()) + .build("TalerMailboxApi.MailboxRateLimitedResponse"); + +export interface MailboxRateLimitedResponse { + + // Taler error code, TALER_EC_MAILBOX_DELIVERY_RATE_LIMITED. + code: number; + + // When the client should retry. + retry_delay: RelativeTime; + + // The human readable error message. + hint: string; + +} + +export interface MailboxMessageDeletionRequest { + + // Number of messages to delete. (Starting from the beginning + // of the latest GET response). + count: Integer; + + // SHA-512 hash over all messages to delete. + checksum: HashCodeString; + + // Signature by the mailbox's private key affirming + // the deletion of the messages, of purpuse + // TALER_SIGNATURE_WALLET_MAILBOX_DELETE_MESSAGES. + wallet_sig: EddsaSignatureString; + +} diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts @@ -1370,6 +1370,31 @@ export interface ContactListResponse { contacts: ContactEntry[]; } +export interface MailboxConfiguration { + mailboxBaseUrl: string; + privateKey: EddsaPrivateKeyString; +} + +export const codecForMailboxConfiguration = (): Codec<MailboxConfiguration> => + buildCodecForObject<MailboxConfiguration>() + .property("mailboxBaseUrl", codecForString()) + .property("privateKey", codecForEddsaPrivateKey()) + .build("MailboxConfiguration"); + + + +export interface AddMailboxMessageRequest { + message: MailboxMessage; +} + +export interface DeleteMailboxMessageRequest { + message: MailboxMessage; +} + +export interface MailboxMessagesResponse { + messages: MailboxMessage[]; +} + export const codecForContactListItem = (): Codec<ContactEntry> => buildCodecForObject<ContactEntry>() .property("alias", codecForString()) @@ -1378,7 +1403,7 @@ export const codecForContactListItem = (): Codec<ContactEntry> => .property("source", codecForString()) .build("ContactListItem"); -export const codecForAddContactRequest = (): Codec<AddContactRequest> => + export const codecForAddContactRequest = (): Codec<AddContactRequest> => buildCodecForObject<AddContactRequest>() .property("contact", codecForContactListItem()) .build("AddContactRequest"); @@ -1388,6 +1413,17 @@ export const codecForDeleteContactRequest = (): Codec<DeleteContactRequest> => .property("contact", codecForContactListItem()) .build("DeleteContactRequest"); +export const codecForAddMailboxMessageRequest = (): Codec<AddMailboxMessageRequest> => + buildCodecForObject<AddMailboxMessageRequest>() + .property("message", codecForAny()) + .build("AddContactRequest"); + +export const codecForDeleteMailboxMessageRequest = (): Codec<DeleteMailboxMessageRequest> => + buildCodecForObject<DeleteMailboxMessageRequest>() + .property("message", codecForAny()) + .build("DeleteContactRequest"); + + export interface WalletCoreVersion { implementationSemver: string; @@ -1745,6 +1781,50 @@ export interface ContactEntry { source: string, } +export enum MailboxMessageType { + PaymentRequestInvoice = "payment-request", + MoneyTransfer = "money-transfer", +} + + +export interface MailboxMessageCommon { + // the type of the message + type: MailboxMessageType; + + // Message ID + id: string; + + // Origin mailbox + originMailboxBaseUrl: string; + + /** + * Raw message body bytes + */ + bodyRaw: string, + + /** + * Sender hint + */ + senderHint: string; + +} + +export type MailboxMessage = + | MailboxMessagePaymentRequestInvoice + | MailboxMessageMoneyTransfer + +export interface MailboxMessagePaymentRequestInvoice extends MailboxMessageCommon { + type: MailboxMessageType.PaymentRequestInvoice; + + payPullUri: string; +} + +export interface MailboxMessageMoneyTransfer extends MailboxMessageCommon { + type: MailboxMessageType.MoneyTransfer; + + payPushUri: string; +} + const codecForAuditorDenomSig = (): Codec<AuditorDenomSig> => buildCodecForObject<AuditorDenomSig>() .property("denom_pub_h", codecForString()) diff --git a/packages/taler-wallet-core/src/contacts.ts b/packages/taler-wallet-core/src/contacts.ts @@ -137,34 +137,11 @@ export async function listContacts( async (tx) => { const contactsRecords = await tx.contacts.iter().toArray(); for (const contactRec of contactsRecords) { - //const taskId = constructTaskIdentifier({ - // tag: PendingTaskType.FriendUpdate, - // fiendAlias: friendRec.alias, - //}); - //const opRetryRecord = await tx.operationRetries.get(taskId); - // FIXME maybe string filter here? - //if (req.filterByScope) { - // const inScope = await checkExchangeInScopeTx( - // tx, - // exchangeRec.baseUrl, - // req.filterByScope, - // ); - // if (!inScope) { - // continue; - // } - //} const li = await makeContactListItem( wex, tx, contactRec, - //opRetryRecord?.lastError, ); - // FIXME aliases can expire, we probably want this. - //if (req.filterByFriendEntryStatus) { - // if (req.filterByFriendEntryStatus !== li.exchangeEntryStatus) { - // continue; - // } - //} contacts.push(li); } }, diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts @@ -48,6 +48,7 @@ import { DenominationInfo, DenominationPubKey, DonationReceiptSignature, + EddsaPrivateKey, EddsaPublicKeyString, EddsaSignatureString, ExchangeAuditor, @@ -55,6 +56,8 @@ import { ExchangeRefundRequest, HashCodeString, Logger, + MailboxMessage, + MailboxConfiguration, MerchantContractTokenDetails, MerchantContractTokenKind, RefreshReason, @@ -1097,6 +1100,8 @@ export interface TokenRecord extends TokenFamilyInfo { blindingKey: string; } + + /** * Slate, a blank slice of rock cut for use as a writing surface, * also the database representation of a token before being @@ -1648,7 +1653,7 @@ export type ConfigRecord = | { key: ConfigRecordKey.TestLoopTx; value: number } | { key: ConfigRecordKey.LastInitInfo; value: DbProtocolTimestamp } | { key: ConfigRecordKey.MaterializedTransactionsVersion; value: number } - | { key: ConfigRecordKey.DonauConfig; value: DonauConfig }; + | { key: ConfigRecordKey.DonauConfig; value: DonauConfig } export interface WalletBackupConfState { deviceId: string; @@ -3181,7 +3186,19 @@ export const WalletStoresV1 = { keyPath: ["alias", "aliasType"], indexes: {}, }), - refreshGroups: describeStore( + mailboxMessages: describeStoreV2({ + recordCodec: passthroughCodec<MailboxMessage>(), + storeName: "mailboxMessages", + keyPath: ["originMailboxBaseUrl", "id"], + indexes: {}, + }), + mailboxConfigurations: describeStoreV2({ + recordCodec: passthroughCodec<MailboxConfiguration>(), + storeName: "mailboxConfigurations", + keyPath: "mailboxBaseUrl", + indexes: {}, + }), + refreshGroups: describeStore( "refreshGroups", describeContents<RefreshGroupRecord>({ keyPath: "refreshGroupId", diff --git a/packages/taler-wallet-core/src/mailbox.ts b/packages/taler-wallet-core/src/mailbox.ts @@ -0,0 +1,145 @@ +/* + This file is part of GNU Taler + (C) 2025 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * @fileoverview + * Implementation of mailbox management in wallet-core. + * The details of mailbox management are specified in DD70. + */ + +import { + EmptyObject, + AddMailboxMessageRequest, + DeleteMailboxMessageRequest, + MailboxMessagesResponse, + Logger, + MailboxMessage, + NotificationType, + EddsaPrivateKey, + createEddsaKeyPair, + encodeCrock, + MailboxConfiguration, +} from "@gnu-taler/taler-util"; +import { + WalletExecutionContext, +} from "./wallet.js"; + + +const logger = new Logger("mailbox.ts"); + +/** + * Add message to the database. + */ +export async function addMailboxMessage( + wex: WalletExecutionContext, + req: AddMailboxMessageRequest, +): Promise<EmptyObject> { + await wex.db.runReadWriteTx( + { + storeNames: [ + "mailboxMessages", + ], + }, + async (tx) => { + tx.mailboxMessages.put(req.message); + tx.notify({ + type: NotificationType.MailboxMessageAdded, + message: req.message, + }); + }, + ); + return { }; +} + +/** + * Delete message from the database. + */ +export async function deleteMailboxMessage( + wex: WalletExecutionContext, + req: DeleteMailboxMessageRequest, +): Promise<EmptyObject> { + await wex.db.runReadWriteTx( + { + storeNames: [ + "mailboxMessages", + ], + }, + async (tx) => { + tx.mailboxMessages.delete ([req.message.originMailboxBaseUrl, req.message.id]); + tx.notify({ + type: NotificationType.MailboxMessageDeleted, + message: req.message, + }); + }, + ); + return { }; +} + +/** + * Get messages from the database. + */ +export async function listMailboxMessages( + wex: WalletExecutionContext, + req: EmptyObject, +): Promise<MailboxMessagesResponse> { + const messages = await wex.db.runReadOnlyTx( + { + storeNames: [ + "mailboxMessages", + ], + }, + async (tx) => { + return await tx.mailboxMessages.getAll(); + }, + ); + return { messages: messages }; +} + +/** + * Get and optionally create mailbox key from the database. + */ +export async function initMailbox( + wex: WalletExecutionContext, + mailboxBaseUrl: string, +): Promise<MailboxConfiguration> { + const res = await wex.db.runReadOnlyTx( + { + storeNames: [ + "mailboxConfigurations", + ], + }, + async (tx) => { + return await tx.mailboxConfigurations.get(mailboxBaseUrl); + }, + ); + if (res) { + return res; + } + const keys = createEddsaKeyPair(); + const privKey = encodeCrock (keys.eddsaPriv); + const mailboxConf = { mailboxBaseUrl: mailboxBaseUrl, privateKey: privKey }; + await wex.db.runReadWriteTx( + { + storeNames: ["mailboxConfigurations"], + }, + async (tx) => { + return await tx.mailboxConfigurations.put(mailboxConf); + }, + ); + return mailboxConf; +} + + diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts @@ -95,6 +95,9 @@ import { GetDonauStatementsRequest, GetDonauStatementsResponse, ContactListResponse, + AddMailboxMessageRequest, + DeleteMailboxMessageRequest, + MailboxMessagesResponse, GetExchangeDetailedInfoRequest, GetExchangeEntryByUrlRequest, GetExchangeEntryByUrlResponse, @@ -186,6 +189,8 @@ import { WithdrawalDetailsForAmount, AddContactRequest, DeleteContactRequest, + EddsaPrivateKey, + MailboxConfiguration, } from "@gnu-taler/taler-util"; import { AddBackupProviderRequest, @@ -244,6 +249,10 @@ export enum WalletApiOperation { AddContact = "addContact", DeleteContact = "deleteContact", GetContacts = "getContacts", + GetMailbox = "getMailbox", + GetMailboxMessages = "getMailboxMessage", + AddMailboxMessage = "addMailboxMessage", + DeleteMailboxMessage = "deleteMailboxMessage", RetryPendingNow = "retryPendingNow", AbortTransaction = "abortTransaction", FailTransaction = "failTransaction", @@ -470,6 +479,45 @@ export type GetContactsOp = { response: ContactListResponse; }; +// group: Mailbox + +/** + * Initialize messages mailbox Op. + */ +export type GetMailboxOp = { + op: WalletApiOperation.GetMailbox; + request: string; + response: MailboxConfiguration; +}; + + +/** + * Get Messages Op. + */ +export type GetMailboxMessagesOp = { + op: WalletApiOperation.GetMailboxMessages; + request: EmptyObject; + response: MailboxMessagesResponse; +}; + +/** + * delete message. + */ +export type DeleteMailboxMessageOp = { + op: WalletApiOperation.DeleteMailboxMessage; + request: DeleteMailboxMessageRequest; + response: EmptyObject; +}; + +/** + * add message. + */ +export type AddMailboxMessageOp = { + op: WalletApiOperation.AddMailboxMessage; + request: AddMailboxMessageRequest; + response: EmptyObject; +}; + // group: Basic Wallet Information @@ -1649,6 +1697,10 @@ export type WalletOperations = { [WalletApiOperation.AddContact]: AddContactOp; [WalletApiOperation.DeleteContact]: DeleteContactOp; [WalletApiOperation.GetContacts]: GetContactsOp; + [WalletApiOperation.GetMailboxMessages]: GetMailboxMessagesOp; + [WalletApiOperation.DeleteMailboxMessage]: DeleteMailboxMessageOp; + [WalletApiOperation.AddMailboxMessage]: AddMailboxMessageOp; + [WalletApiOperation.GetMailbox]: GetMailboxOp; }; export type WalletCoreRequestType< diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts @@ -221,6 +221,8 @@ import { codecForSetDonauRequest, codecForAddContactRequest, codecForDeleteContactRequest, + codecForAddMailboxMessageRequest, + codecForDeleteMailboxMessageRequest, codecForSetWalletDeviceIdRequest, codecForSharePaymentRequest, codecForStartExchangeWalletKycRequest, @@ -253,6 +255,7 @@ import { setGlobalLogLevelFromString, stringifyScopeInfo, validateIban, + codecForString, } from "@gnu-taler/taler-util"; import { readSuccessResponseJsonOrThrow, @@ -315,6 +318,7 @@ import { handleSetDonau, } from "./donau.js"; import { listContacts, addContact, deleteContact } from "./contacts.js"; +import { listMailboxMessages, addMailboxMessage, deleteMailboxMessage, initMailbox } from "./mailbox.js"; import { ReadyExchangeSummary, acceptExchangeTermsOfService, @@ -1919,6 +1923,22 @@ const handlers: { [T in WalletApiOperation]: HandlerWithValidator<T> } = { codec: codecForEmptyObject(), handler: listContacts, }, + [WalletApiOperation.GetMailbox]: { + codec: codecForString(), + handler: initMailbox, + }, + [WalletApiOperation.AddMailboxMessage]: { + codec: codecForAddMailboxMessageRequest(), + handler: addMailboxMessage, + }, + [WalletApiOperation.DeleteMailboxMessage]: { + codec: codecForDeleteMailboxMessageRequest(), + handler: deleteMailboxMessage, + }, + [WalletApiOperation.GetMailboxMessages]: { + codec: codecForEmptyObject(), + handler: listMailboxMessages, + }, [WalletApiOperation.TestingGetDbStats]: { codec: codecForEmptyObject(), handler: async (wex) => { diff --git a/packages/taler-wallet-webextension/src/NavigationBar.tsx b/packages/taler-wallet-webextension/src/NavigationBar.tsx @@ -122,6 +122,7 @@ export const Pages = { "/contacts/:tid?", ), contactsAdd: "/contacts/add/", + mailbox: "/mailbox", sendCash: pageDefinition<{ scope: CrockEncodedString; amount?: string }>( "/destination/send/:scope/:amount?", ), @@ -275,7 +276,7 @@ export function PopupNavBar({ path }: { path?: PopupNavBarOptions }): VNode { </NavigationHeader> ); } -export type WalletNavBarOptions = "balance" | "backup" | "dev" | "contacts"; +export type WalletNavBarOptions = "balance" | "backup" | "dev" | "contacts" | "mailbox"; export function WalletNavBar({ path }: { path?: WalletNavBarOptions }): VNode { const { i18n } = useTranslationContext(); @@ -324,7 +325,10 @@ export function WalletNavBar({ path }: { path?: WalletNavBarOptions }): VNode { <a href={`#${Pages.contacts({})}`} class={path === "contacts" ? "active" : ""}> <i18n.Translate>Contacts</i18n.Translate> </a> - </EnabledBySettings> + <a href={`#${Pages.mailbox}`} class={path === "mailbox" ? "active" : ""}> + <i18n.Translate>Mailbox</i18n.Translate> + </a> + </EnabledBySettings> <div style={{ display: "flex", paddingTop: 4, justifyContent: "right" }} diff --git a/packages/taler-wallet-webextension/src/components/WalletActivity.tsx b/packages/taler-wallet-webextension/src/components/WalletActivity.tsx @@ -854,6 +854,10 @@ export function ObservabilityEventsTable(): VNode { return i18n.str`Contact added`; case NotificationType.ContactDeleted: return i18n.str`Contact deleted`; + case NotificationType.MailboxMessageAdded: + return i18n.str`Mailbox message added`; + case NotificationType.MailboxMessageDeleted: + return i18n.str`Mailbox message deleted`; default: { assertUnreachable(not.type); } diff --git a/packages/taler-wallet-webextension/src/wallet/Application.tsx b/packages/taler-wallet-webextension/src/wallet/Application.tsx @@ -98,6 +98,7 @@ import { ContactsPage } from "./Contacts.js"; import { SupportedBanksForAccount } from "./SupportedBanksForAccount.js"; import { TransactionPage } from "./Transaction.js"; import { WelcomePage } from "./Welcome.js"; +import { MailboxPage } from "./Mailbox.js"; export function Application(): VNode { const { i18n } = useTranslationContext(); @@ -168,6 +169,17 @@ export function Application(): VNode { )} /> <Route + path={Pages.mailbox} + component={() => ( + <WalletTemplate + goToTransaction={redirectToTxInfo} + goToURL={redirectToURL} + > + <MailboxPage /> + </WalletTemplate> + )} + /> + <Route path={Pages.notifications} component={() => ( <WalletTemplate goToURL={redirectToURL}> @@ -190,7 +202,7 @@ export function Application(): VNode { path={Pages.contactsAdd} component={() => ( <WalletTemplate goToURL={redirectToURL}> - <AddContact onBack={() => redirectTo(Pages.contacts.pattern)} /> + <AddContact onBack={() => redirectTo(Pages.contacts({}))} /> </WalletTemplate> )} /> diff --git a/packages/taler-wallet-webextension/src/wallet/Contacts.tsx b/packages/taler-wallet-webextension/src/wallet/Contacts.tsx @@ -189,14 +189,14 @@ export function ContactsView({ <i18n.Translate>Add new contact</i18n.Translate> </a> </Centered> - {!contacts.length && ( + {(contacts.length == 0) ? ( <Centered style={{ marginTop: 20 }}> <BoldLight> <i18n.Translate>No contacts yet.</i18n.Translate> </BoldLight> </Centered> - )} - {contacts.length && ( + ) : + ( <TextField label="Search" variant="filled" diff --git a/packages/taler-wallet-webextension/src/wallet/Mailbox.tsx b/packages/taler-wallet-webextension/src/wallet/Mailbox.tsx @@ -0,0 +1,237 @@ +/* + This file is part of GNU Taler + (C) 2025 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { + assertUnreachable, + ScopeInfo, + NotificationType, + MailboxMessage, + MailboxMessageType, + decodeCrock, + eddsaGetPublic, + encodeCrock, + MailboxConfiguration, +} from "@gnu-taler/taler-util"; +import { SafeHandler } from "../mui/handlers.js"; +import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Link } from "preact-router"; +import { Fragment, VNode, h } from "preact"; +import { useEffect, useState } from "preact/hooks"; +import { ErrorAlertView } from "../components/CurrentAlerts.js"; +import { Loading } from "../components/Loading.js"; +import { + BoldLight, + Centered, + RowBorderGray, + SmallText, + SmallLightText, + LightText, +} from "../components/styled/index.js"; +import { alertFromError, useAlertContext } from "../context/alert.js"; +import { useBackendContext } from "../context/backend.js"; +import { useAsyncAsHook } from "../hooks/useAsyncAsHook.js"; +import { Button } from "../mui/Button.js"; +import { Grid } from "../mui/Grid.js"; +import { Paper } from "../mui/Paper.js"; +import { TextField } from "../mui/TextField.js"; +import { TextFieldHandler } from "../mui/handlers.js"; + +interface Props { + scope?: ScopeInfo; + search?: boolean; +} + +export function MailboxPage({ + scope, + search: showSearch, +}: Props): VNode { + const { i18n } = useTranslationContext(); + const api = useBackendContext(); + const [search, setSearch] = useState<string>(); + // FIXME get from settings; + const mailboxBaseUrl = "http://localhost:11000"; + + const state = useAsyncAsHook(async () => { + const b = await api.wallet.call(WalletApiOperation.GetMailboxMessages, {}); + const activeMailbox = await api.wallet.call(WalletApiOperation.GetMailbox, mailboxBaseUrl); + + // FIXME put this into the mailbox config directly? + const privKey = decodeCrock(activeMailbox.privateKey); + const pubKey = eddsaGetPublic(privKey); + const mailboxUrl = activeMailbox.mailboxBaseUrl + '/' + encodeCrock(pubKey); + return { + messages: b.messages, + mailbox: activeMailbox, + mailboxUrl: mailboxUrl, + }; + }, [search]); + + const { pushAlertOnError } = useAlertContext(); + + if (!state) { + return <Loading />; + } + + useEffect(() => { + return api.listener.onUpdateNotification( + [NotificationType.MailboxMessageAdded, NotificationType.MailboxMessageDeleted], + state?.retry, + ); + }); + if (state.hasError) { + return ( + <ErrorAlertView + error={alertFromError( + i18n, + i18n.str`Could not load the list of messages`, + state, + )} + /> + ); + } + + const onDeleteMessage = async (m: MailboxMessage) => { + api.wallet.call(WalletApiOperation.DeleteMailboxMessage, { message: m }).then(); + }; + return ( + <MessagesView + search={{ + value: search ?? "", + onInput: pushAlertOnError(async (d: string) => { + setSearch(d); + }), + }} + messages={state.response.messages} + mailboxUrl={state.response.mailboxUrl} + onDeleteMessage={onDeleteMessage} + /> + ); +} + +interface MessageProps { + message: MailboxMessage; + onDeleteMessage: (m: MailboxMessage) => Promise<void>; +} + + +function MailboxMessageLayout(props: MessageProps): VNode { + const { i18n } = useTranslationContext(); + switch (props.message.type) { + case MailboxMessageType.MoneyTransfer: + return ( + <Paper style={{ padding: 8 }}> + <p> + <span>Money transfer from {props.message.senderHint}</span> + <SmallText style={{ marginTop: 5 }}> + <i18n.Translate>URI:</i18n.Translate>: {props.message.payPushUri} + </SmallText> + </p> + <Button variant="contained" onClick={() => { return props.onDeleteMessage(props.message)}} color="error"> + <i18n.Translate>Delete</i18n.Translate> + </Button> + </Paper> + ) + case MailboxMessageType.PaymentRequestInvoice: + return ( + <Paper style={{ padding: 8 }}> + <p> + <span>Payment request from {props.message.senderHint}</span> + <SmallText style={{ marginTop: 5 }}> + <i18n.Translate>URI</i18n.Translate>: {props.message.payPullUri} + </SmallText> + </p> + <Button variant="contained" onClick={() => { return props.onDeleteMessage(props.message)}} color="error"> + <i18n.Translate>Delete</i18n.Translate> + </Button> + </Paper> + ) + default: + assertUnreachable(props.message); + + + } +} + +export function MessagesView({ + search, + messages, + mailboxUrl, + onDeleteMessage, +}: { + search: TextFieldHandler; + messages: MailboxMessage[]; + mailboxUrl: string; + onDeleteMessage: (m: MailboxMessage) => Promise<void>; +}): VNode { + const { i18n } = useTranslationContext(); + async function copy(): Promise<void> { + navigator.clipboard.writeText(mailboxUrl); + } + return ( + <Fragment> + <section> + <Centered style={{ margin: 10 }}> + <p> + <SmallText style={{ marginTop: 5 }}> + <LightText> + <i18n.Translate>Mailbox</i18n.Translate>: {mailboxUrl} + </LightText> + <Button onClick={copy as SafeHandler<void>}> + <i18n.Translate>copy</i18n.Translate> + </Button> + </SmallText> + </p> + <Button variant="contained" color="error"> + <i18n.Translate>Fetch messages</i18n.Translate> + </Button> + </Centered> + {(messages.length == 0) ? ( + <Centered style={{ marginTop: 20 }}> + <BoldLight> + <i18n.Translate>No messages yet.</i18n.Translate> + </BoldLight> + </Centered> + ) : + ( + <TextField + label="Search" + variant="filled" + error={search.error} + required + fullWidth + value={search.value} + onChange={search.onInput} + /> + )} + <Grid item container columns={1} spacing={1} style={{ marginTop: 20 }}> + { + messages.map((m, i) => ( + <Grid item xs={1}> + <MailboxMessageLayout + message={m} + onDeleteMessage={onDeleteMessage} + /> + </Grid> + )) + } + </Grid> + </section> + </Fragment> + ); +} + +