@open-mercato/core 0.9.1-develop.7513.1.ae2715553f → 0.9.1-develop.7516.1.176184247d
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/communication_channels/lib/send-as-user.js.map +2 -2
- package/dist/modules/customers/api/people/[id]/emails/route.js +44 -1
- package/dist/modules/customers/api/people/[id]/emails/route.js.map +2 -2
- package/dist/modules/customers/lib/personEmailThreads.js +36 -1
- package/dist/modules/customers/lib/personEmailThreads.js.map +2 -2
- package/dist/modules/messages/api/[id]/route.js +3 -9
- package/dist/modules/messages/api/[id]/route.js.map +2 -2
- package/dist/modules/messages/api/route.js +33 -16
- package/dist/modules/messages/api/route.js.map +2 -2
- package/dist/modules/messages/commands/messages.js.map +2 -2
- package/dist/modules/messages/lib/routeHelpers.js +40 -1
- package/dist/modules/messages/lib/routeHelpers.js.map +2 -2
- package/dist/modules/shipping_carriers/api/webhook/[provider]/route.js +4 -7
- package/dist/modules/shipping_carriers/api/webhook/[provider]/route.js.map +2 -2
- package/dist/modules/shipping_carriers/lib/shipping-service.js +29 -11
- package/dist/modules/shipping_carriers/lib/shipping-service.js.map +2 -2
- package/dist/modules/shipping_carriers/lib/status-sync.js +2 -2
- package/dist/modules/shipping_carriers/lib/status-sync.js.map +2 -2
- package/dist/modules/shipping_carriers/workers/webhook-processor.js +60 -33
- package/dist/modules/shipping_carriers/workers/webhook-processor.js.map +3 -3
- package/dist/modules/warranty_claims/commands/claim-lines.js +89 -68
- package/dist/modules/warranty_claims/commands/claim-lines.js.map +2 -2
- package/dist/modules/warranty_claims/commands/claims.js +14 -13
- package/dist/modules/warranty_claims/commands/claims.js.map +2 -2
- package/dist/modules/warranty_claims/commands/shared.js +8 -2
- package/dist/modules/warranty_claims/commands/shared.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/communication_channels/lib/send-as-user.ts +4 -0
- package/src/modules/customers/api/people/[id]/emails/route.ts +67 -1
- package/src/modules/customers/i18n/de.json +1 -0
- package/src/modules/customers/i18n/en.json +1 -0
- package/src/modules/customers/i18n/es.json +1 -0
- package/src/modules/customers/i18n/ko.json +1 -0
- package/src/modules/customers/i18n/pl.json +1 -0
- package/src/modules/customers/lib/personEmailThreads.ts +61 -0
- package/src/modules/messages/api/[id]/route.ts +2 -9
- package/src/modules/messages/api/route.ts +42 -20
- package/src/modules/messages/commands/messages.ts +7 -0
- package/src/modules/messages/i18n/de.json +1 -0
- package/src/modules/messages/i18n/en.json +1 -0
- package/src/modules/messages/i18n/es.json +1 -0
- package/src/modules/messages/i18n/ko.json +1 -0
- package/src/modules/messages/i18n/pl.json +1 -0
- package/src/modules/messages/lib/routeHelpers.ts +84 -1
- package/src/modules/shipping_carriers/api/webhook/[provider]/route.ts +4 -7
- package/src/modules/shipping_carriers/lib/shipping-service.ts +29 -11
- package/src/modules/shipping_carriers/lib/status-sync.ts +2 -2
- package/src/modules/shipping_carriers/workers/webhook-processor.ts +71 -39
- package/src/modules/warranty_claims/commands/claim-lines.ts +107 -66
- package/src/modules/warranty_claims/commands/claims.ts +14 -13
- package/src/modules/warranty_claims/commands/shared.ts +7 -1
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { randomUUID } from "crypto";
|
|
2
|
-
import { assertFound } from "@open-mercato/shared/lib/crud/errors";
|
|
2
|
+
import { CrudHttpError, assertFound } from "@open-mercato/shared/lib/crud/errors";
|
|
3
3
|
import { invalidateCrudCache } from "@open-mercato/shared/lib/crud/cache";
|
|
4
4
|
import { enforceCommandOptimisticLockWithGuards } from "@open-mercato/shared/lib/crud/optimistic-lock-command";
|
|
5
5
|
import { withAtomicFlush } from "@open-mercato/shared/lib/commands/flush";
|
|
6
6
|
import { emitCrudSideEffects, flushCrudSideEffects } from "@open-mercato/shared/lib/commands/helpers";
|
|
7
7
|
import { findOneWithDecryption, findWithDecryption } from "@open-mercato/shared/lib/encryption/find";
|
|
8
|
+
import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
|
|
8
9
|
import {
|
|
9
10
|
WarrantyClaim,
|
|
10
11
|
WarrantyClaimEvent,
|
|
@@ -26,6 +27,10 @@ async function enforceWarrantyClaimOptimisticLock(ctx, record, resourceKind = WA
|
|
|
26
27
|
request: ctx.request ?? null
|
|
27
28
|
});
|
|
28
29
|
}
|
|
30
|
+
async function throwUndoRefusal(key, fallback) {
|
|
31
|
+
const { translate } = await resolveTranslations();
|
|
32
|
+
throw new CrudHttpError(409, { error: translate(key, fallback), code: key });
|
|
33
|
+
}
|
|
29
34
|
async function loadScopedClaim(em, id, scope, options = {}) {
|
|
30
35
|
return findOneWithDecryption(
|
|
31
36
|
em,
|
|
@@ -157,6 +162,7 @@ export {
|
|
|
157
162
|
extractUndoPayload,
|
|
158
163
|
loadScopedClaim,
|
|
159
164
|
reconcileVendorRecoverySourceClaim,
|
|
160
|
-
requireScopedClaim
|
|
165
|
+
requireScopedClaim,
|
|
166
|
+
throwUndoRefusal
|
|
161
167
|
};
|
|
162
168
|
//# sourceMappingURL=shared.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/warranty_claims/commands/shared.ts"],
|
|
4
|
-
"sourcesContent": ["import { randomUUID } from 'crypto'\nimport type { FindOneOptions } from '@mikro-orm/core'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\nimport { assertFound } from '@open-mercato/shared/lib/crud/errors'\nimport { invalidateCrudCache } from '@open-mercato/shared/lib/crud/cache'\nimport { enforceCommandOptimisticLockWithGuards } from '@open-mercato/shared/lib/crud/optimistic-lock-command'\nimport { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'\nimport { emitCrudSideEffects, flushCrudSideEffects } from '@open-mercato/shared/lib/commands/helpers'\nimport type { DataEngine } from '@open-mercato/shared/lib/data/engine'\nimport { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport {\n WarrantyClaim,\n WarrantyClaimEvent,\n WarrantyClaimLine,\n} from '../data/entities'\nimport type {\n WarrantyClaimEventKind,\n WarrantyClaimEventVisibility,\n} from '../data/validators'\nimport { computeHeaderRollups } from '../lib/stateMachine'\n\nexport { assertFound } from '@open-mercato/shared/lib/crud/errors'\nexport { ensureOrganizationScope, ensureSameScope, ensureTenantScope } from '@open-mercato/shared/lib/commands/scope'\nexport { extractUndoPayload } from '@open-mercato/shared/lib/commands/undo'\n\nexport const WARRANTY_CLAIM_RESOURCE_KIND = 'warranty_claims.claim'\nexport const WARRANTY_CLAIM_LINE_RESOURCE_KIND = 'warranty_claims.claim_line'\n\nexport type WarrantyClaimScope = {\n organizationId: string\n tenantId: string\n}\n\nexport type VersionedRecord = {\n id: string\n updatedAt?: Date | string | null\n}\n\nexport type ResolverContext = {\n resolve: <T = unknown>(name: string) => T\n}\n\nexport type AppendClaimEventInput = {\n visibility?: WarrantyClaimEventVisibility\n body?: string | null\n payload?: Record<string, unknown> | null\n actorUserId?: string | null\n actorCustomerId?: string | null\n}\n\nexport async function enforceWarrantyClaimOptimisticLock(\n ctx: CommandRuntimeContext,\n record: VersionedRecord | null | undefined,\n resourceKind = WARRANTY_CLAIM_RESOURCE_KIND,\n expected?: string | Date | null,\n): Promise<void> {\n if (!record) return\n await enforceCommandOptimisticLockWithGuards(ctx.container, {\n resourceKind,\n resourceId: record.id,\n current: record.updatedAt ?? null,\n expected,\n request: ctx.request ?? null,\n })\n}\n\nexport async function loadScopedClaim(\n em: EntityManager,\n id: string,\n scope: WarrantyClaimScope,\n options: FindOneOptions<WarrantyClaim> = {},\n): Promise<WarrantyClaim | null> {\n return findOneWithDecryption(\n em,\n WarrantyClaim,\n { id, tenantId: scope.tenantId, organizationId: scope.organizationId, deletedAt: null },\n options,\n scope,\n )\n}\n\n/**\n * `notFoundMessage` is passed to `assertFound` verbatim, so request-scoped callers\n * must hand in an already-translated sentence; the key default only suits the\n * command surface, which runs without a request locale.\n */\nexport async function requireScopedClaim(\n em: EntityManager,\n id: string,\n scope: WarrantyClaimScope,\n options: FindOneOptions<WarrantyClaim> = {},\n notFoundMessage = 'warranty_claims.errors.notFound',\n): Promise<WarrantyClaim> {\n const claim = await loadScopedClaim(em, id, scope, options)\n return assertFound(claim, notFoundMessage)\n}\n\nexport function appendClaimEvent(\n em: EntityManager,\n claim: WarrantyClaim,\n kind: WarrantyClaimEventKind,\n input: AppendClaimEventInput = {},\n): WarrantyClaimEvent {\n const event = em.create(WarrantyClaimEvent, {\n id: randomUUID(),\n claim,\n organizationId: claim.organizationId,\n tenantId: claim.tenantId,\n kind,\n visibility: input.visibility ?? 'internal',\n body: input.body ?? null,\n payload: input.payload ?? null,\n actorUserId: input.actorUserId ?? null,\n actorCustomerId: input.actorCustomerId ?? null,\n createdAt: new Date(),\n })\n em.persist(event)\n return event\n}\n\nexport async function reconcileVendorRecoverySourceClaim(\n ctx: ResolverContext,\n input: { claimId: string; tenantId: string; organizationId: string },\n): Promise<void> {\n const em = (ctx.resolve('em') as EntityManager).fork()\n const scope = { tenantId: input.tenantId, organizationId: input.organizationId }\n const recoveryClaim = await findOneWithDecryption(\n em,\n WarrantyClaim,\n { id: input.claimId, tenantId: scope.tenantId, organizationId: scope.organizationId, deletedAt: null },\n {},\n scope,\n )\n if (!recoveryClaim || recoveryClaim.claimType !== 'vendor_recovery' || !recoveryClaim.sourceClaimId) return\n\n const sourceClaim = await findOneWithDecryption(\n em,\n WarrantyClaim,\n { id: recoveryClaim.sourceClaimId, tenantId: scope.tenantId, organizationId: scope.organizationId, deletedAt: null },\n {},\n scope,\n )\n if (!sourceClaim) return\n\n const resolvedChildren = await findWithDecryption(\n em,\n WarrantyClaim,\n {\n sourceClaimId: sourceClaim.id,\n claimType: 'vendor_recovery',\n status: { $in: ['resolved', 'closed'] },\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n deletedAt: null,\n },\n {},\n scope,\n )\n const childLines = resolvedChildren.length\n ? await findWithDecryption(\n em,\n WarrantyClaimLine,\n {\n claim: { $in: resolvedChildren.map((child) => child.id) },\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n deletedAt: null,\n },\n {},\n scope,\n )\n : []\n const recoveredTotal = computeHeaderRollups(childLines).totalApprovedAmount\n await withAtomicFlush(\n em,\n [\n () => {\n sourceClaim.totalRecoveredAmount = String(recoveredTotal)\n sourceClaim.updatedAt = new Date()\n },\n ],\n { transaction: true, label: 'warranty_claims.vendor_recovery.reconciliation' },\n )\n\n const dataEngine = ctx.resolve<DataEngine>('dataEngine')\n await emitCrudSideEffects({\n dataEngine,\n action: 'updated',\n entity: sourceClaim,\n identifiers: {\n id: sourceClaim.id,\n organizationId: sourceClaim.organizationId,\n tenantId: sourceClaim.tenantId,\n },\n indexer: { entityType: 'warranty_claims:warranty_claim' },\n events: {\n module: 'warranty_claims',\n entity: 'claim',\n persistent: true,\n buildPayload: () => ({\n id: sourceClaim.id,\n organizationId: sourceClaim.organizationId,\n tenantId: sourceClaim.tenantId,\n claimType: sourceClaim.claimType,\n status: sourceClaim.status,\n }),\n },\n })\n await flushCrudSideEffects(dataEngine)\n await invalidateCrudCache(\n ctx as unknown as Parameters<typeof invalidateCrudCache>[0],\n 'warranty_claims.claim',\n { id: sourceClaim.id, organizationId: sourceClaim.organizationId, tenantId: sourceClaim.tenantId },\n input.tenantId,\n 'warranty_claims.vendor_recovery.reconciliation',\n )\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,kBAAkB;AAI3B,SAAS,mBAAmB;
|
|
4
|
+
"sourcesContent": ["import { randomUUID } from 'crypto'\nimport type { FindOneOptions } from '@mikro-orm/core'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\nimport { CrudHttpError, assertFound } from '@open-mercato/shared/lib/crud/errors'\nimport { invalidateCrudCache } from '@open-mercato/shared/lib/crud/cache'\nimport { enforceCommandOptimisticLockWithGuards } from '@open-mercato/shared/lib/crud/optimistic-lock-command'\nimport { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'\nimport { emitCrudSideEffects, flushCrudSideEffects } from '@open-mercato/shared/lib/commands/helpers'\nimport type { DataEngine } from '@open-mercato/shared/lib/data/engine'\nimport { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport {\n WarrantyClaim,\n WarrantyClaimEvent,\n WarrantyClaimLine,\n} from '../data/entities'\nimport type {\n WarrantyClaimEventKind,\n WarrantyClaimEventVisibility,\n} from '../data/validators'\nimport { computeHeaderRollups } from '../lib/stateMachine'\n\nexport { assertFound } from '@open-mercato/shared/lib/crud/errors'\nexport { ensureOrganizationScope, ensureSameScope, ensureTenantScope } from '@open-mercato/shared/lib/commands/scope'\nexport { extractUndoPayload } from '@open-mercato/shared/lib/commands/undo'\n\nexport const WARRANTY_CLAIM_RESOURCE_KIND = 'warranty_claims.claim'\nexport const WARRANTY_CLAIM_LINE_RESOURCE_KIND = 'warranty_claims.claim_line'\n\nexport type WarrantyClaimScope = {\n organizationId: string\n tenantId: string\n}\n\nexport type VersionedRecord = {\n id: string\n updatedAt?: Date | string | null\n}\n\nexport type ResolverContext = {\n resolve: <T = unknown>(name: string) => T\n}\n\nexport type AppendClaimEventInput = {\n visibility?: WarrantyClaimEventVisibility\n body?: string | null\n payload?: Record<string, unknown> | null\n actorUserId?: string | null\n actorCustomerId?: string | null\n}\n\nexport async function enforceWarrantyClaimOptimisticLock(\n ctx: CommandRuntimeContext,\n record: VersionedRecord | null | undefined,\n resourceKind = WARRANTY_CLAIM_RESOURCE_KIND,\n expected?: string | Date | null,\n): Promise<void> {\n if (!record) return\n await enforceCommandOptimisticLockWithGuards(ctx.container, {\n resourceKind,\n resourceId: record.id,\n current: record.updatedAt ?? null,\n expected,\n request: ctx.request ?? null,\n })\n}\n\nexport async function throwUndoRefusal(key: string, fallback: string): Promise<never> {\n const { translate } = await resolveTranslations()\n throw new CrudHttpError(409, { error: translate(key, fallback), code: key })\n}\n\nexport async function loadScopedClaim(\n em: EntityManager,\n id: string,\n scope: WarrantyClaimScope,\n options: FindOneOptions<WarrantyClaim> = {},\n): Promise<WarrantyClaim | null> {\n return findOneWithDecryption(\n em,\n WarrantyClaim,\n { id, tenantId: scope.tenantId, organizationId: scope.organizationId, deletedAt: null },\n options,\n scope,\n )\n}\n\n/**\n * `notFoundMessage` is passed to `assertFound` verbatim, so request-scoped callers\n * must hand in an already-translated sentence; the key default only suits the\n * command surface, which runs without a request locale.\n */\nexport async function requireScopedClaim(\n em: EntityManager,\n id: string,\n scope: WarrantyClaimScope,\n options: FindOneOptions<WarrantyClaim> = {},\n notFoundMessage = 'warranty_claims.errors.notFound',\n): Promise<WarrantyClaim> {\n const claim = await loadScopedClaim(em, id, scope, options)\n return assertFound(claim, notFoundMessage)\n}\n\nexport function appendClaimEvent(\n em: EntityManager,\n claim: WarrantyClaim,\n kind: WarrantyClaimEventKind,\n input: AppendClaimEventInput = {},\n): WarrantyClaimEvent {\n const event = em.create(WarrantyClaimEvent, {\n id: randomUUID(),\n claim,\n organizationId: claim.organizationId,\n tenantId: claim.tenantId,\n kind,\n visibility: input.visibility ?? 'internal',\n body: input.body ?? null,\n payload: input.payload ?? null,\n actorUserId: input.actorUserId ?? null,\n actorCustomerId: input.actorCustomerId ?? null,\n createdAt: new Date(),\n })\n em.persist(event)\n return event\n}\n\nexport async function reconcileVendorRecoverySourceClaim(\n ctx: ResolverContext,\n input: { claimId: string; tenantId: string; organizationId: string },\n): Promise<void> {\n const em = (ctx.resolve('em') as EntityManager).fork()\n const scope = { tenantId: input.tenantId, organizationId: input.organizationId }\n const recoveryClaim = await findOneWithDecryption(\n em,\n WarrantyClaim,\n { id: input.claimId, tenantId: scope.tenantId, organizationId: scope.organizationId, deletedAt: null },\n {},\n scope,\n )\n if (!recoveryClaim || recoveryClaim.claimType !== 'vendor_recovery' || !recoveryClaim.sourceClaimId) return\n\n const sourceClaim = await findOneWithDecryption(\n em,\n WarrantyClaim,\n { id: recoveryClaim.sourceClaimId, tenantId: scope.tenantId, organizationId: scope.organizationId, deletedAt: null },\n {},\n scope,\n )\n if (!sourceClaim) return\n\n const resolvedChildren = await findWithDecryption(\n em,\n WarrantyClaim,\n {\n sourceClaimId: sourceClaim.id,\n claimType: 'vendor_recovery',\n status: { $in: ['resolved', 'closed'] },\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n deletedAt: null,\n },\n {},\n scope,\n )\n const childLines = resolvedChildren.length\n ? await findWithDecryption(\n em,\n WarrantyClaimLine,\n {\n claim: { $in: resolvedChildren.map((child) => child.id) },\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n deletedAt: null,\n },\n {},\n scope,\n )\n : []\n const recoveredTotal = computeHeaderRollups(childLines).totalApprovedAmount\n await withAtomicFlush(\n em,\n [\n () => {\n sourceClaim.totalRecoveredAmount = String(recoveredTotal)\n sourceClaim.updatedAt = new Date()\n },\n ],\n { transaction: true, label: 'warranty_claims.vendor_recovery.reconciliation' },\n )\n\n const dataEngine = ctx.resolve<DataEngine>('dataEngine')\n await emitCrudSideEffects({\n dataEngine,\n action: 'updated',\n entity: sourceClaim,\n identifiers: {\n id: sourceClaim.id,\n organizationId: sourceClaim.organizationId,\n tenantId: sourceClaim.tenantId,\n },\n indexer: { entityType: 'warranty_claims:warranty_claim' },\n events: {\n module: 'warranty_claims',\n entity: 'claim',\n persistent: true,\n buildPayload: () => ({\n id: sourceClaim.id,\n organizationId: sourceClaim.organizationId,\n tenantId: sourceClaim.tenantId,\n claimType: sourceClaim.claimType,\n status: sourceClaim.status,\n }),\n },\n })\n await flushCrudSideEffects(dataEngine)\n await invalidateCrudCache(\n ctx as unknown as Parameters<typeof invalidateCrudCache>[0],\n 'warranty_claims.claim',\n { id: sourceClaim.id, organizationId: sourceClaim.organizationId, tenantId: sourceClaim.tenantId },\n input.tenantId,\n 'warranty_claims.vendor_recovery.reconciliation',\n )\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["assertFound"]
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/core",
|
|
3
|
-
"version": "0.9.1-develop.
|
|
3
|
+
"version": "0.9.1-develop.7516.1.176184247d",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -264,16 +264,16 @@
|
|
|
264
264
|
"zod": "^4.4.3"
|
|
265
265
|
},
|
|
266
266
|
"peerDependencies": {
|
|
267
|
-
"@open-mercato/ai-assistant": "0.9.1-develop.
|
|
268
|
-
"@open-mercato/shared": "0.9.1-develop.
|
|
269
|
-
"@open-mercato/ui": "0.9.1-develop.
|
|
267
|
+
"@open-mercato/ai-assistant": "0.9.1-develop.7516.1.176184247d",
|
|
268
|
+
"@open-mercato/shared": "0.9.1-develop.7516.1.176184247d",
|
|
269
|
+
"@open-mercato/ui": "0.9.1-develop.7516.1.176184247d",
|
|
270
270
|
"react": "^19.0.0",
|
|
271
271
|
"react-dom": "^19.0.0"
|
|
272
272
|
},
|
|
273
273
|
"devDependencies": {
|
|
274
|
-
"@open-mercato/ai-assistant": "0.9.1-develop.
|
|
275
|
-
"@open-mercato/shared": "0.9.1-develop.
|
|
276
|
-
"@open-mercato/ui": "0.9.1-develop.
|
|
274
|
+
"@open-mercato/ai-assistant": "0.9.1-develop.7516.1.176184247d",
|
|
275
|
+
"@open-mercato/shared": "0.9.1-develop.7516.1.176184247d",
|
|
276
|
+
"@open-mercato/ui": "0.9.1-develop.7516.1.176184247d",
|
|
277
277
|
"@testing-library/dom": "^10.4.1",
|
|
278
278
|
"@testing-library/jest-dom": "^7.0.1",
|
|
279
279
|
"@testing-library/react": "^16.3.3",
|
|
@@ -39,6 +39,10 @@ export type SendAsUserInput = {
|
|
|
39
39
|
* derives `threadId` from the parent), so a CRM reply continues the existing
|
|
40
40
|
* conversation instead of starting a new thread. Optional — omitted for new
|
|
41
41
|
* threads.
|
|
42
|
+
*
|
|
43
|
+
* Not authorized here: the facade serves more than one surface, and each owns
|
|
44
|
+
* what "may reply to" means. The caller MUST have established that the actor
|
|
45
|
+
* can read this message under its own read rule before passing it.
|
|
42
46
|
*/
|
|
43
47
|
parentMessageId?: string
|
|
44
48
|
/**
|
|
@@ -12,8 +12,11 @@ import {
|
|
|
12
12
|
import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
|
|
13
13
|
import { resolveSingleOrganizationIdOrDeny } from '@open-mercato/core/modules/directory/utils/organizationScopeFilter'
|
|
14
14
|
import { isOrganizationReadAccessAllowed } from '@open-mercato/core/modules/directory/utils/organizationScopeGuard'
|
|
15
|
+
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
15
16
|
import { resolveAuthActorId } from '../../../../lib/interactionRequestContext'
|
|
16
17
|
import { CustomerEntity } from '../../../../data/entities'
|
|
18
|
+
import { listGrantsForViewerOnPerson, listSharedChannelIds } from '../../../../lib/conversationShares'
|
|
19
|
+
import { isMessageOnPersonEmailHistory } from '../../../../lib/personEmailThreads'
|
|
17
20
|
import type { SendAsUserService } from '@open-mercato/core/modules/communication_channels/lib/send-as-user'
|
|
18
21
|
|
|
19
22
|
export const metadata = {
|
|
@@ -40,6 +43,29 @@ const composeSchema = z
|
|
|
40
43
|
})
|
|
41
44
|
.strict()
|
|
42
45
|
|
|
46
|
+
type RbacService = {
|
|
47
|
+
userHasAllFeatures: (
|
|
48
|
+
userId: string,
|
|
49
|
+
required: string[],
|
|
50
|
+
scope: { tenantId: string | null; organizationId: string | null },
|
|
51
|
+
) => Promise<boolean>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function callerHasFeature(
|
|
55
|
+
container: { resolve: (name: string) => unknown },
|
|
56
|
+
userId: string,
|
|
57
|
+
feature: string,
|
|
58
|
+
scope: { tenantId: string; organizationId: string | null },
|
|
59
|
+
): Promise<boolean> {
|
|
60
|
+
try {
|
|
61
|
+
const rbac = container.resolve('rbacService') as RbacService | undefined
|
|
62
|
+
if (typeof rbac?.userHasAllFeatures !== 'function') return false
|
|
63
|
+
return await rbac.userHasAllFeatures(userId, [feature], scope)
|
|
64
|
+
} catch {
|
|
65
|
+
return false
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
43
69
|
type RouteContext = {
|
|
44
70
|
params: Promise<{ id: string }> | { id: string }
|
|
45
71
|
}
|
|
@@ -96,6 +122,46 @@ export async function POST(req: Request, context: RouteContext): Promise<Respons
|
|
|
96
122
|
return NextResponse.json({ error: 'Person not found' }, { status: 404 })
|
|
97
123
|
}
|
|
98
124
|
|
|
125
|
+
// A reply parent decides which thread the sent email joins, so it must be an
|
|
126
|
+
// email this caller could see on this Person's Emails tab — the same feature,
|
|
127
|
+
// anchor and visibility rule as `GET .../email-threads`, including conversation
|
|
128
|
+
// shares and shared team mailboxes. Anything else answers like an unknown id.
|
|
129
|
+
if (body.parentMessageId) {
|
|
130
|
+
const viewerUserId = auth.isApiKey ? null : auth.sub ?? null
|
|
131
|
+
const shareScope = { tenantId: auth.tenantId as string, organizationId }
|
|
132
|
+
const canViewPeople = await callerHasFeature(container, userId, 'customers.people.view', shareScope)
|
|
133
|
+
const [sharedConversations, sharedChannelIds] = canViewPeople
|
|
134
|
+
? await Promise.all([
|
|
135
|
+
listGrantsForViewerOnPerson(em, shareScope, viewerUserId, personId),
|
|
136
|
+
listSharedChannelIds(em, shareScope, viewerUserId),
|
|
137
|
+
])
|
|
138
|
+
: [[], []]
|
|
139
|
+
const parentVisible =
|
|
140
|
+
canViewPeople &&
|
|
141
|
+
(await isMessageOnPersonEmailHistory(em, {
|
|
142
|
+
personId,
|
|
143
|
+
tenantId: auth.tenantId as string,
|
|
144
|
+
organizationId,
|
|
145
|
+
viewerUserId,
|
|
146
|
+
userFeatures: undefined,
|
|
147
|
+
sharedConversations,
|
|
148
|
+
sharedChannelIds,
|
|
149
|
+
messageId: body.parentMessageId,
|
|
150
|
+
}))
|
|
151
|
+
if (!parentVisible) {
|
|
152
|
+
const { translate } = await resolveTranslations()
|
|
153
|
+
return NextResponse.json(
|
|
154
|
+
{
|
|
155
|
+
error: translate(
|
|
156
|
+
'customers.email.errors.parentMessageNotFound',
|
|
157
|
+
'The email you are replying to was not found',
|
|
158
|
+
),
|
|
159
|
+
},
|
|
160
|
+
{ status: 404 },
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
99
165
|
const guardResult = await validateCrudMutationGuard(container, {
|
|
100
166
|
tenantId: auth.tenantId,
|
|
101
167
|
organizationId,
|
|
@@ -178,7 +244,7 @@ export const openApi = {
|
|
|
178
244
|
{ status: 400, description: 'Invalid person id' },
|
|
179
245
|
{ status: 401, description: 'Unauthorized' },
|
|
180
246
|
{ status: 403, description: 'Missing customers.email.compose feature or mutation guard rejection' },
|
|
181
|
-
{ status: 404, description: 'Person or channel not found' },
|
|
247
|
+
{ status: 404, description: 'Person or channel not found, or the reply parent is not an email the caller can see on this Person' },
|
|
182
248
|
{ status: 409, description: 'Channel not connected' },
|
|
183
249
|
{ status: 422, description: 'Invalid request body' },
|
|
184
250
|
{ status: 500, description: 'Send failed' },
|
|
@@ -1691,6 +1691,7 @@
|
|
|
1691
1691
|
"customers.email.conversationShare.sharedByTeammate": "Von einem Kollegen geteilt",
|
|
1692
1692
|
"customers.email.errors.deliveryFailed": "Zustellung fehlgeschlagen – nicht gesendet",
|
|
1693
1693
|
"customers.email.errors.flipFailed": "Aktualisierung der Sichtbarkeit fehlgeschlagen",
|
|
1694
|
+
"customers.email.errors.parentMessageNotFound": "Die E-Mail, auf die Sie antworten, wurde nicht gefunden",
|
|
1694
1695
|
"customers.email.errors.sendFailed": "Senden fehlgeschlagen",
|
|
1695
1696
|
"customers.email.sync.button": "Synchronisieren",
|
|
1696
1697
|
"customers.email.sync.failed": "Postfach konnte nicht synchronisiert werden",
|
|
@@ -1691,6 +1691,7 @@
|
|
|
1691
1691
|
"customers.email.conversationShare.sharedByTeammate": "Shared by a teammate",
|
|
1692
1692
|
"customers.email.errors.deliveryFailed": "Delivery failed — not sent",
|
|
1693
1693
|
"customers.email.errors.flipFailed": "Visibility update failed",
|
|
1694
|
+
"customers.email.errors.parentMessageNotFound": "The email you are replying to was not found",
|
|
1694
1695
|
"customers.email.errors.sendFailed": "Send failed",
|
|
1695
1696
|
"customers.email.sync.button": "Sync",
|
|
1696
1697
|
"customers.email.sync.failed": "Failed to sync mailbox",
|
|
@@ -1691,6 +1691,7 @@
|
|
|
1691
1691
|
"customers.email.conversationShare.sharedByTeammate": "Compartido por un compañero",
|
|
1692
1692
|
"customers.email.errors.deliveryFailed": "Error de entrega: no enviado",
|
|
1693
1693
|
"customers.email.errors.flipFailed": "Error al actualizar la visibilidad",
|
|
1694
|
+
"customers.email.errors.parentMessageNotFound": "No se encontró el correo al que está respondiendo",
|
|
1694
1695
|
"customers.email.errors.sendFailed": "Error al enviar",
|
|
1695
1696
|
"customers.email.sync.button": "Sincronizar",
|
|
1696
1697
|
"customers.email.sync.failed": "No se pudo sincronizar el buzón",
|
|
@@ -1691,6 +1691,7 @@
|
|
|
1691
1691
|
"customers.email.conversationShare.sharedByTeammate": "동료가 공유",
|
|
1692
1692
|
"customers.email.errors.deliveryFailed": "전송 실패 — 발송되지 않음",
|
|
1693
1693
|
"customers.email.errors.flipFailed": "공개 범위 업데이트 실패",
|
|
1694
|
+
"customers.email.errors.parentMessageNotFound": "답장하려는 이메일을 찾을 수 없습니다",
|
|
1694
1695
|
"customers.email.errors.sendFailed": "전송 실패",
|
|
1695
1696
|
"customers.email.sync.button": "동기화",
|
|
1696
1697
|
"customers.email.sync.failed": "메일함 동기화에 실패했습니다",
|
|
@@ -1691,6 +1691,7 @@
|
|
|
1691
1691
|
"customers.email.conversationShare.sharedByTeammate": "Udostępnione przez współpracownika",
|
|
1692
1692
|
"customers.email.errors.deliveryFailed": "Dostarczanie nie powiodło się – nie wysłano",
|
|
1693
1693
|
"customers.email.errors.flipFailed": "Aktualizacja widoczności nieudana",
|
|
1694
|
+
"customers.email.errors.parentMessageNotFound": "Nie znaleziono e-maila, na który odpowiadasz",
|
|
1694
1695
|
"customers.email.errors.sendFailed": "Wysyłanie nieudane",
|
|
1695
1696
|
"customers.email.sync.button": "Synchronizuj",
|
|
1696
1697
|
"customers.email.sync.failed": "Nie udało się zsynchronizować skrzynki",
|
|
@@ -378,3 +378,64 @@ export async function buildPersonEmailThreads(
|
|
|
378
378
|
threads.sort((a, b) => b.lastMessageAt.localeCompare(a.lastMessageAt))
|
|
379
379
|
return threads.slice(0, maxThreads)
|
|
380
380
|
}
|
|
381
|
+
|
|
382
|
+
export type PersonEmailMessageAccessOptions = Pick<
|
|
383
|
+
BuildPersonEmailThreadsOptions,
|
|
384
|
+
'personId' | 'tenantId' | 'organizationId' | 'viewerUserId' | 'userFeatures' | 'sharedConversations' | 'sharedChannelIds'
|
|
385
|
+
> & {
|
|
386
|
+
/** Open Mercato `messages.message` id the caller wants to reply to. */
|
|
387
|
+
messageId: string
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Whether `messageId` is on this Person's email history as the viewer sees it —
|
|
392
|
+
* i.e. whether {@link buildPersonEmailThreads} would show it to them.
|
|
393
|
+
*
|
|
394
|
+
* Walks the same anchor (email `CustomerInteraction` → `MessageChannelLink` →
|
|
395
|
+
* message) with the same scope and the same visibility predicate, so a reply
|
|
396
|
+
* from the Person page can only name a parent the Emails tab could have shown:
|
|
397
|
+
* the viewer's own mail, shared or legacy mail, a conversation shared for THIS
|
|
398
|
+
* Person, or a shared team mailbox. Callers remain responsible for resolving the
|
|
399
|
+
* Person in scope first.
|
|
400
|
+
*/
|
|
401
|
+
export async function isMessageOnPersonEmailHistory(
|
|
402
|
+
em: EntityManager,
|
|
403
|
+
opts: PersonEmailMessageAccessOptions,
|
|
404
|
+
): Promise<boolean> {
|
|
405
|
+
const { personId, tenantId, organizationId, messageId } = opts
|
|
406
|
+
const dscope = { tenantId, organizationId: organizationId ?? null }
|
|
407
|
+
|
|
408
|
+
const linkWhere: JsonRecord = { messageId, tenantId }
|
|
409
|
+
if (organizationId) linkWhere.organizationId = organizationId
|
|
410
|
+
const links = (await findWithDecryption(
|
|
411
|
+
em,
|
|
412
|
+
'MessageChannelLink' as never,
|
|
413
|
+
linkWhere as never,
|
|
414
|
+
undefined,
|
|
415
|
+
dscope,
|
|
416
|
+
)) as JsonRecord[]
|
|
417
|
+
const linkIds = links
|
|
418
|
+
.map((link) => (typeof link.id === 'string' ? (link.id as string) : null))
|
|
419
|
+
.filter((value): value is string => !!value)
|
|
420
|
+
if (linkIds.length === 0) return false
|
|
421
|
+
|
|
422
|
+
const interactionWhere: JsonRecord = {
|
|
423
|
+
entity: personId,
|
|
424
|
+
interactionType: 'email',
|
|
425
|
+
deletedAt: null,
|
|
426
|
+
tenantId,
|
|
427
|
+
externalMessageId: { $in: linkIds },
|
|
428
|
+
}
|
|
429
|
+
if (organizationId) interactionWhere.organizationId = organizationId
|
|
430
|
+
Object.assign(
|
|
431
|
+
interactionWhere,
|
|
432
|
+
buildEmailVisibilityMikroFilter({
|
|
433
|
+
currentUserId: opts.viewerUserId,
|
|
434
|
+
userFeatures: opts.userFeatures,
|
|
435
|
+
sharedConversations: opts.sharedConversations,
|
|
436
|
+
sharedChannelIds: opts.sharedChannelIds,
|
|
437
|
+
}),
|
|
438
|
+
)
|
|
439
|
+
const visible = await em.count(CustomerInteraction, interactionWhere as never)
|
|
440
|
+
return visible > 0
|
|
441
|
+
}
|
|
@@ -16,9 +16,9 @@ import { getMessageTypeOrDefault } from '../../lib/message-types-registry'
|
|
|
16
16
|
import { attachOperationMetadataHeader } from '../../lib/operationMetadata'
|
|
17
17
|
import {
|
|
18
18
|
canPostToChannelThread,
|
|
19
|
-
hasChannelThreadReadAccess,
|
|
20
19
|
hasOrganizationAccess,
|
|
21
20
|
resolveMessageContext,
|
|
21
|
+
resolveMessageReadAccess,
|
|
22
22
|
} from '../../lib/routeHelpers'
|
|
23
23
|
import { resolveUserFeatures, runMessageMutationGuardAfterSuccess, runMessageMutationGuards } from '../guards'
|
|
24
24
|
import {
|
|
@@ -103,13 +103,7 @@ export async function GET(req: Request, { params }: { params: { id: string } })
|
|
|
103
103
|
return Response.json({ error: 'Access denied' }, { status: 403 })
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
const recipient = await
|
|
107
|
-
messageId: params.id,
|
|
108
|
-
recipientUserId: scope.userId,
|
|
109
|
-
deletedAt: null,
|
|
110
|
-
})
|
|
111
|
-
|
|
112
|
-
const isSender = message.senderUserId === scope.userId
|
|
106
|
+
const { recipient, isSender, hasChannelThreadAccess } = await resolveMessageReadAccess(ctx, scope, message)
|
|
113
107
|
const isRecipient = Boolean(recipient)
|
|
114
108
|
|
|
115
109
|
// #5535: a message that arrived over a communication channel has the channel
|
|
@@ -126,7 +120,6 @@ export async function GET(req: Request, { params }: { params: { id: string } })
|
|
|
126
120
|
// `requireAuth` only so that a participant can always read their own message.
|
|
127
121
|
// The gate goes through RBAC, not through `ctx.auth.features` — the session JWT
|
|
128
122
|
// carries no `features` claim at all, so reading it would deny everyone.
|
|
129
|
-
const hasChannelThreadAccess = await hasChannelThreadReadAccess(ctx, scope, message)
|
|
130
123
|
|
|
131
124
|
if (!isSender && !isRecipient && !hasChannelThreadAccess) {
|
|
132
125
|
return Response.json({ error: 'Access denied' }, { status: 403 })
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
resolveCrudCache,
|
|
13
13
|
} from '@open-mercato/shared/lib/crud/cache'
|
|
14
14
|
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi/types'
|
|
15
|
-
import {
|
|
15
|
+
import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
16
16
|
import { lookupHashCandidates } from '@open-mercato/shared/lib/encryption/aes'
|
|
17
17
|
import { User } from '../../auth/data/entities'
|
|
18
18
|
import { Message, MessageObject } from '../data/entities'
|
|
@@ -36,6 +36,7 @@ import { attachOperationMetadataHeader } from '../lib/operationMetadata'
|
|
|
36
36
|
import {
|
|
37
37
|
canPostToChannelThread,
|
|
38
38
|
canUseChannelThreadFallback,
|
|
39
|
+
resolveReplyParentMessage,
|
|
39
40
|
canUseMessageEmailFeature,
|
|
40
41
|
resolveMessageContext,
|
|
41
42
|
} from '../lib/routeHelpers'
|
|
@@ -46,6 +47,7 @@ import { MessageCommandExecuteResult } from '../commands/shared'
|
|
|
46
47
|
import {
|
|
47
48
|
composeMessageRequestSchema as composeSchema,
|
|
48
49
|
composeResponseSchema,
|
|
50
|
+
errorResponseSchema,
|
|
49
51
|
listMessagesSchema as listSchema,
|
|
50
52
|
messageListItemSchema,
|
|
51
53
|
} from './openapi'
|
|
@@ -510,6 +512,35 @@ export async function POST(req: Request) {
|
|
|
510
512
|
}
|
|
511
513
|
}
|
|
512
514
|
|
|
515
|
+
// A parent decides which conversation the new message joins, so it is held to
|
|
516
|
+
// the same rule as reading it: knowing a message id is not enough to thread
|
|
517
|
+
// onto someone else's conversation. Checked on the parent the client sent, for
|
|
518
|
+
// drafts and every visibility, before any guard, delegation or write runs.
|
|
519
|
+
let replyParent: Message | null = null
|
|
520
|
+
if (input.parentMessageId) {
|
|
521
|
+
const resolution = await resolveReplyParentMessage(
|
|
522
|
+
ctx,
|
|
523
|
+
{ tenantId: scope.tenantId, organizationId: scope.organizationId ?? null, userId: scope.userId },
|
|
524
|
+
input.parentMessageId,
|
|
525
|
+
)
|
|
526
|
+
if (resolution.status === 'not_found') {
|
|
527
|
+
const { translate } = await resolveTranslations()
|
|
528
|
+
return Response.json(
|
|
529
|
+
{
|
|
530
|
+
error: translate(
|
|
531
|
+
'messages.errors.parentMessageNotFound',
|
|
532
|
+
'The message you are replying to was not found',
|
|
533
|
+
),
|
|
534
|
+
},
|
|
535
|
+
{ status: 404 },
|
|
536
|
+
)
|
|
537
|
+
}
|
|
538
|
+
if (resolution.status === 'forbidden') {
|
|
539
|
+
return Response.json({ error: 'Access denied' }, { status: 403 })
|
|
540
|
+
}
|
|
541
|
+
replyParent = resolution.message
|
|
542
|
+
}
|
|
543
|
+
|
|
513
544
|
// #5535: composing on a channel conversation without naming a parent message
|
|
514
545
|
// used to open a brand-new thread. A fresh thread has no `ChannelThreadMapping`,
|
|
515
546
|
// so the outbound bridge had nothing to route on and the operator got a 201 for
|
|
@@ -555,27 +586,14 @@ export async function POST(req: Request) {
|
|
|
555
586
|
// derives the thread from that parent alone — so a public, non-draft message
|
|
556
587
|
// could be threaded onto a channel-linked conversation and delivered to the
|
|
557
588
|
// external correspondent without any channel-access check. A tenant-wide
|
|
558
|
-
// channel accepts delivery from any sender, so
|
|
559
|
-
//
|
|
560
|
-
// actually derive. An internal thread
|
|
561
|
-
//
|
|
589
|
+
// channel accepts delivery from any sender, so reading a message is not
|
|
590
|
+
// enough to post onto its thread. Apply the same check to the thread the
|
|
591
|
+
// command will actually derive. An internal thread and an absent hub resolve
|
|
592
|
+
// to `null` and keep the pre-existing rule.
|
|
562
593
|
// Both channel branches also require `messages.view`, like reply and forward:
|
|
563
594
|
// the hub grants every shared channel regardless of features (#6432).
|
|
564
|
-
if (isPublicVisibility && !input.isDraft &&
|
|
565
|
-
const
|
|
566
|
-
const parentMessage = await findOneWithDecryption(
|
|
567
|
-
em,
|
|
568
|
-
Message,
|
|
569
|
-
{
|
|
570
|
-
id: input.parentMessageId,
|
|
571
|
-
tenantId: scope.tenantId,
|
|
572
|
-
organizationId: scope.organizationId,
|
|
573
|
-
deletedAt: null,
|
|
574
|
-
},
|
|
575
|
-
undefined,
|
|
576
|
-
{ tenantId: scope.tenantId, organizationId: scope.organizationId },
|
|
577
|
-
)
|
|
578
|
-
const parentThreadId = parentMessage?.threadId ?? input.parentMessageId
|
|
595
|
+
if (isPublicVisibility && !input.isDraft && replyParent) {
|
|
596
|
+
const parentThreadId = replyParent.threadId ?? replyParent.id
|
|
579
597
|
if (!(await canPostToChannelThread(ctx, scope, parentThreadId))) {
|
|
580
598
|
return Response.json({ error: 'Access denied' }, { status: 403 })
|
|
581
599
|
}
|
|
@@ -711,6 +729,10 @@ export const openApi: OpenApiRouteDoc = {
|
|
|
711
729
|
schema: composeResponseSchema,
|
|
712
730
|
},
|
|
713
731
|
],
|
|
732
|
+
errors: [
|
|
733
|
+
{ status: 403, description: 'Access denied, including a parentMessageId the caller cannot read', schema: errorResponseSchema },
|
|
734
|
+
{ status: 404, description: 'parentMessageId is unknown, deleted, or outside the caller organization', schema: errorResponseSchema },
|
|
735
|
+
],
|
|
714
736
|
},
|
|
715
737
|
},
|
|
716
738
|
}
|
|
@@ -266,6 +266,13 @@ async function buildComposeResultFromExisting(
|
|
|
266
266
|
}
|
|
267
267
|
}
|
|
268
268
|
|
|
269
|
+
/**
|
|
270
|
+
* `parentMessageId` is trusted: the thread is derived from it without a read
|
|
271
|
+
* check, because the acting `userId` is itself an input and server-side callers
|
|
272
|
+
* (channel ingest) pass parents the actor is not a participant of. Every surface
|
|
273
|
+
* that accepts a parent from a caller MUST resolve it against its own read rule
|
|
274
|
+
* first (`resolveReplyParentMessage` for the messages API).
|
|
275
|
+
*/
|
|
269
276
|
const composeMessageCommand: CommandHandler<unknown, { id: string; threadId: string | null; externalEmail: string | null; isDraft: boolean; recipientUserIds: string[]; deduplicated?: boolean }> = {
|
|
270
277
|
id: 'messages.messages.compose',
|
|
271
278
|
async execute(rawInput, ctx) {
|
|
@@ -130,6 +130,7 @@
|
|
|
130
130
|
"messages.errors.noExternalEmail": "Bitte gib eine gültige externe E-Mail ein.",
|
|
131
131
|
"messages.errors.noRecipients": "Füge mindestens einen Empfänger hinzu.",
|
|
132
132
|
"messages.errors.noSubject": "Bitte Betreff eingeben.",
|
|
133
|
+
"messages.errors.parentMessageNotFound": "Die Nachricht, auf die Sie antworten, wurde nicht gefunden",
|
|
133
134
|
"messages.errors.sendFailed": "Nachricht konnte nicht gesendet werden.",
|
|
134
135
|
"messages.errors.senderDraftSendUnsupported": "Das Senden eines gespeicherten Entwurfs über ein verbundenes Postfach wird noch nicht unterstützt.",
|
|
135
136
|
"messages.errors.senderPayloadUnsupported": "Der Versand aus dem eigenen Postfach unterstützt noch keine Anhänge oder verknüpften Datensätze.",
|
|
@@ -130,6 +130,7 @@
|
|
|
130
130
|
"messages.errors.noExternalEmail": "Please enter a valid external email.",
|
|
131
131
|
"messages.errors.noRecipients": "Please add at least one recipient.",
|
|
132
132
|
"messages.errors.noSubject": "Please enter a subject.",
|
|
133
|
+
"messages.errors.parentMessageNotFound": "The message you are replying to was not found",
|
|
133
134
|
"messages.errors.sendFailed": "Failed to send message.",
|
|
134
135
|
"messages.errors.senderDraftSendUnsupported": "Sending a saved draft through a connected mailbox is not supported yet.",
|
|
135
136
|
"messages.errors.senderPayloadUnsupported": "Sending from your own mailbox does not support attachments or linked records yet.",
|
|
@@ -130,6 +130,7 @@
|
|
|
130
130
|
"messages.errors.noExternalEmail": "Introduce un correo externo válido.",
|
|
131
131
|
"messages.errors.noRecipients": "Añade al menos un destinatario.",
|
|
132
132
|
"messages.errors.noSubject": "Introduce un asunto.",
|
|
133
|
+
"messages.errors.parentMessageNotFound": "No se encontró el mensaje al que está respondiendo",
|
|
133
134
|
"messages.errors.sendFailed": "No se pudo enviar el mensaje.",
|
|
134
135
|
"messages.errors.senderDraftSendUnsupported": "Enviar un borrador guardado desde un buzón conectado todavía no es compatible.",
|
|
135
136
|
"messages.errors.senderPayloadUnsupported": "El envío desde tu propio buzón todavía no admite adjuntos ni registros vinculados.",
|
|
@@ -130,6 +130,7 @@
|
|
|
130
130
|
"messages.errors.noExternalEmail": "유효한 외부 이메일을 입력하세요.",
|
|
131
131
|
"messages.errors.noRecipients": "수신자를 하나 이상 추가하세요.",
|
|
132
132
|
"messages.errors.noSubject": "제목을 입력하세요.",
|
|
133
|
+
"messages.errors.parentMessageNotFound": "답장하려는 메시지를 찾을 수 없습니다",
|
|
133
134
|
"messages.errors.sendFailed": "메시지 전송에 실패했습니다.",
|
|
134
135
|
"messages.errors.senderDraftSendUnsupported": "저장된 초안을 연결된 메일함으로 보내는 기능은 아직 지원되지 않습니다.",
|
|
135
136
|
"messages.errors.senderPayloadUnsupported": "본인 메일함에서 보내기는 아직 첨부 파일이나 연결된 레코드를 지원하지 않습니다.",
|
|
@@ -130,6 +130,7 @@
|
|
|
130
130
|
"messages.errors.noExternalEmail": "Wpisz poprawny zewnętrzny adres e-mail.",
|
|
131
131
|
"messages.errors.noRecipients": "Dodaj przynajmniej jednego odbiorcę.",
|
|
132
132
|
"messages.errors.noSubject": "Wpisz temat.",
|
|
133
|
+
"messages.errors.parentMessageNotFound": "Nie znaleziono wiadomości, na którą odpowiadasz",
|
|
133
134
|
"messages.errors.sendFailed": "Nie udało się wysłać wiadomości.",
|
|
134
135
|
"messages.errors.senderDraftSendUnsupported": "Wysyłka zapisanego szkicu przez połączoną skrzynkę nie jest jeszcze obsługiwana.",
|
|
135
136
|
"messages.errors.senderPayloadUnsupported": "Wysyłka z własnej skrzynki nie obsługuje jeszcze załączników ani powiązanych rekordów.",
|