@remit/backend 0.0.1
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/.env.test +7 -0
- package/dev-server/content-auth.test.ts +113 -0
- package/dev-server/content-auth.ts +54 -0
- package/dev-server/content-handler.test.ts +105 -0
- package/dev-server/content-handler.ts +79 -0
- package/dev-server/content-path.test.ts +37 -0
- package/dev-server/content-path.ts +27 -0
- package/dev-server/cors.test.ts +48 -0
- package/dev-server/cors.ts +25 -0
- package/dev-server/lambda-helpers.ts +69 -0
- package/dev-server/server.ts +289 -0
- package/package.json +82 -0
- package/src/auth.ts +92 -0
- package/src/config/msoauth.ts +60 -0
- package/src/data-backend.test.ts +25 -0
- package/src/data-backend.ts +20 -0
- package/src/derive/autoMoved.ts +28 -0
- package/src/derive/contentSignature.test.ts +153 -0
- package/src/derive/contentSignature.ts +139 -0
- package/src/derive/contentUrl.test.ts +156 -0
- package/src/derive/contentUrl.ts +70 -0
- package/src/derive/enrichThreadRows.ts +155 -0
- package/src/derive/filterThreadCriteria.test.ts +119 -0
- package/src/derive/filterThreadCriteria.ts +60 -0
- package/src/derive/pendingMoveCounts.ts +90 -0
- package/src/derive/senderTrust.test.ts +67 -0
- package/src/derive/senderTrust.ts +23 -0
- package/src/error.ts +55 -0
- package/src/handlers/account-guards.ts +137 -0
- package/src/handlers/account-oauth.test.ts +383 -0
- package/src/handlers/account-oauth.ts +452 -0
- package/src/handlers/account-overrides.test.ts +69 -0
- package/src/handlers/account-overrides.ts +286 -0
- package/src/handlers/account-ownership.ts +25 -0
- package/src/handlers/account-signature.ts +129 -0
- package/src/handlers/account.ts +524 -0
- package/src/handlers/address.ts +136 -0
- package/src/handlers/config.test.ts +184 -0
- package/src/handlers/config.ts +219 -0
- package/src/handlers/ensure-account-config.ts +30 -0
- package/src/handlers/filter.ts +294 -0
- package/src/handlers/folder-role-appointments.test.ts +250 -0
- package/src/handlers/folder-role-appointments.ts +272 -0
- package/src/handlers/folder-role.ts +76 -0
- package/src/handlers/index.ts +48 -0
- package/src/handlers/mailbox.ts +411 -0
- package/src/handlers/me.ts +190 -0
- package/src/handlers/message.ts +886 -0
- package/src/handlers/organize.test.ts +43 -0
- package/src/handlers/organize.ts +196 -0
- package/src/handlers/outbox.ts +218 -0
- package/src/handlers/search.test.ts +182 -0
- package/src/handlers/search.ts +105 -0
- package/src/handlers/sync-progress.test.ts +158 -0
- package/src/handlers/sync-progress.ts +64 -0
- package/src/handlers/sync.test.ts +146 -0
- package/src/handlers/sync.ts +119 -0
- package/src/handlers/thread.ts +361 -0
- package/src/handlers/unified-threads.ts +196 -0
- package/src/handlers/vip-suggestions.ts +21 -0
- package/src/index.ts +170 -0
- package/src/json.ts +11 -0
- package/src/jwt-auth.test.ts +89 -0
- package/src/jwt-auth.ts +95 -0
- package/src/request-context.test.ts +61 -0
- package/src/request-context.ts +29 -0
- package/src/request.ts +10 -0
- package/src/response.test.ts +107 -0
- package/src/response.ts +80 -0
- package/src/service/compose-postgres.ts +72 -0
- package/src/service/compose-sqlite.ts +80 -0
- package/src/service/create-remit-client.ts +358 -0
- package/src/service/dynamodb.test.ts +100 -0
- package/src/service/dynamodb.ts +58 -0
- package/src/service/filter.ts +55 -0
- package/src/service/fire-and-forget.test.ts +126 -0
- package/src/service/fire-and-forget.ts +79 -0
- package/src/service/localhost-env-config.test.ts +40 -0
- package/src/service/organize.test.ts +384 -0
- package/src/service/organize.ts +354 -0
- package/src/service/semantic-capability.test.ts +64 -0
- package/src/service/semantic-capability.ts +62 -0
- package/src/service/sqs.ts +4 -0
- package/src/service/trigger-sync.test.ts +211 -0
- package/src/service/trigger-sync.ts +102 -0
- package/src/types.ts +161 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AccountConfigRepo,
|
|
3
|
+
AccountExportRequestRepo,
|
|
4
|
+
AccountRepo,
|
|
5
|
+
AccountSettingRepo,
|
|
6
|
+
AddressRepo,
|
|
7
|
+
DrizzleEnvelopeRepository,
|
|
8
|
+
DrizzleMessageFlagRepository,
|
|
9
|
+
DrizzleMessageRepository,
|
|
10
|
+
DrizzleThreadMessageRepository,
|
|
11
|
+
DrizzleUnitOfWork,
|
|
12
|
+
FilterAnchorRepo,
|
|
13
|
+
FilterRepo,
|
|
14
|
+
LabelRepo,
|
|
15
|
+
MailboxLockRepo,
|
|
16
|
+
MailboxRepo,
|
|
17
|
+
MailboxSpecialUseRepo,
|
|
18
|
+
MessageFlagPushRepo,
|
|
19
|
+
MessageLabelRepo,
|
|
20
|
+
MessagePlacementMoveRepo,
|
|
21
|
+
messageDataSchema,
|
|
22
|
+
OrganizeJobRequestRepo,
|
|
23
|
+
OutboxMessageRepo,
|
|
24
|
+
} from "@remit/drizzle-service";
|
|
25
|
+
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
26
|
+
import { env } from "expect-env";
|
|
27
|
+
import {
|
|
28
|
+
buildSharedDeps,
|
|
29
|
+
createRemitClient,
|
|
30
|
+
type RemitClient,
|
|
31
|
+
type RemitClientRepositories,
|
|
32
|
+
} from "./create-remit-client.js";
|
|
33
|
+
|
|
34
|
+
export const buildPostgresClient = (): RemitClient => {
|
|
35
|
+
const pgConnectionUrl = env.PG_CONNECTION_URL;
|
|
36
|
+
|
|
37
|
+
// One drizzle db instance shared across repos.
|
|
38
|
+
// The schema is registered for message-data tables; i4 repos use the same
|
|
39
|
+
// underlying connection and only need the builder API (no relational queries).
|
|
40
|
+
const db = drizzle(pgConnectionUrl, { schema: messageDataSchema });
|
|
41
|
+
const genericDb = db as unknown as NodePgDatabase<Record<string, unknown>>;
|
|
42
|
+
|
|
43
|
+
const messageDataDb = db as unknown as NodePgDatabase<
|
|
44
|
+
typeof messageDataSchema
|
|
45
|
+
>;
|
|
46
|
+
|
|
47
|
+
const repositories: RemitClientRepositories = {
|
|
48
|
+
accountConfig: new AccountConfigRepo(genericDb),
|
|
49
|
+
account: new AccountRepo(genericDb),
|
|
50
|
+
accountSetting: new AccountSettingRepo(genericDb),
|
|
51
|
+
address: new AddressRepo(genericDb),
|
|
52
|
+
mailbox: new MailboxRepo(genericDb),
|
|
53
|
+
mailboxSpecialUse: new MailboxSpecialUseRepo(genericDb),
|
|
54
|
+
mailboxLock: new MailboxLockRepo(genericDb),
|
|
55
|
+
message: new DrizzleMessageRepository(messageDataDb),
|
|
56
|
+
messageFlag: new DrizzleMessageFlagRepository(messageDataDb),
|
|
57
|
+
outboxMessage: new OutboxMessageRepo(genericDb),
|
|
58
|
+
threadMessage: new DrizzleThreadMessageRepository(pgConnectionUrl),
|
|
59
|
+
envelope: new DrizzleEnvelopeRepository(messageDataDb),
|
|
60
|
+
accountExportRequest: new AccountExportRequestRepo(genericDb),
|
|
61
|
+
organizeJobRequest: new OrganizeJobRequestRepo(genericDb),
|
|
62
|
+
placementMove: new MessagePlacementMoveRepo(genericDb),
|
|
63
|
+
flagPush: new MessageFlagPushRepo(genericDb),
|
|
64
|
+
filter: new FilterRepo(genericDb),
|
|
65
|
+
filterAnchor: new FilterAnchorRepo(genericDb),
|
|
66
|
+
label: new LabelRepo(genericDb),
|
|
67
|
+
messageLabel: new MessageLabelRepo(genericDb),
|
|
68
|
+
unitOfWork: new DrizzleUnitOfWork(messageDataDb),
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
return createRemitClient({ repositories, ...buildSharedDeps() });
|
|
72
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AccountConfigRepo,
|
|
3
|
+
AccountExportRequestRepo,
|
|
4
|
+
AccountRepo,
|
|
5
|
+
AccountSettingRepo,
|
|
6
|
+
AddressRepo,
|
|
7
|
+
createSqliteDatabase,
|
|
8
|
+
DrizzleEnvelopeRepository,
|
|
9
|
+
DrizzleMessageFlagRepository,
|
|
10
|
+
DrizzleMessageRepository,
|
|
11
|
+
DrizzleThreadMessageRepository,
|
|
12
|
+
DrizzleUnitOfWork,
|
|
13
|
+
FilterAnchorRepo,
|
|
14
|
+
FilterRepo,
|
|
15
|
+
LabelRepo,
|
|
16
|
+
MailboxLockRepo,
|
|
17
|
+
MailboxRepo,
|
|
18
|
+
MailboxSpecialUseRepo,
|
|
19
|
+
MessageFlagPushRepo,
|
|
20
|
+
MessageLabelRepo,
|
|
21
|
+
MessagePlacementMoveRepo,
|
|
22
|
+
messageDataSchema,
|
|
23
|
+
OrganizeJobRequestRepo,
|
|
24
|
+
OutboxMessageRepo,
|
|
25
|
+
} from "@remit/drizzle-service";
|
|
26
|
+
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
27
|
+
import { env } from "expect-env";
|
|
28
|
+
import {
|
|
29
|
+
buildSharedDeps,
|
|
30
|
+
createRemitClient,
|
|
31
|
+
type RemitClient,
|
|
32
|
+
type RemitClientRepositories,
|
|
33
|
+
} from "./create-remit-client.js";
|
|
34
|
+
|
|
35
|
+
// The SQLite backend (RFC 036). Mirrors the Postgres composition — the same
|
|
36
|
+
// dialect-neutral Drizzle repos, wired to one shared SQLite file instead of a
|
|
37
|
+
// Postgres server — with two differences the file topology forces (D3): every
|
|
38
|
+
// repo runs on the single serialized connection `createSqliteDatabase` opens
|
|
39
|
+
// (writes cannot bypass serialization, so a plain repo write never joins an
|
|
40
|
+
// open transaction's savepoint), and `threadMessage` takes that shared handle
|
|
41
|
+
// rather than its own connection string, so its writes enlist in the same
|
|
42
|
+
// unit-of-work transaction and the same write queue as everything else.
|
|
43
|
+
export const buildSqliteClient = async (): Promise<RemitClient> => {
|
|
44
|
+
const sqliteDbPath = env.SQLITE_DB_PATH;
|
|
45
|
+
|
|
46
|
+
const { db } = await createSqliteDatabase(messageDataSchema, {
|
|
47
|
+
filename: sqliteDbPath,
|
|
48
|
+
});
|
|
49
|
+
const genericDb = db as unknown as NodePgDatabase<Record<string, unknown>>;
|
|
50
|
+
|
|
51
|
+
const messageDataDb = db as unknown as NodePgDatabase<
|
|
52
|
+
typeof messageDataSchema
|
|
53
|
+
>;
|
|
54
|
+
|
|
55
|
+
const repositories: RemitClientRepositories = {
|
|
56
|
+
accountConfig: new AccountConfigRepo(genericDb),
|
|
57
|
+
account: new AccountRepo(genericDb),
|
|
58
|
+
accountSetting: new AccountSettingRepo(genericDb),
|
|
59
|
+
address: new AddressRepo(genericDb),
|
|
60
|
+
mailbox: new MailboxRepo(genericDb),
|
|
61
|
+
mailboxSpecialUse: new MailboxSpecialUseRepo(genericDb),
|
|
62
|
+
mailboxLock: new MailboxLockRepo(genericDb),
|
|
63
|
+
message: new DrizzleMessageRepository(messageDataDb),
|
|
64
|
+
messageFlag: new DrizzleMessageFlagRepository(messageDataDb),
|
|
65
|
+
outboxMessage: new OutboxMessageRepo(genericDb),
|
|
66
|
+
threadMessage: new DrizzleThreadMessageRepository(genericDb),
|
|
67
|
+
envelope: new DrizzleEnvelopeRepository(messageDataDb),
|
|
68
|
+
accountExportRequest: new AccountExportRequestRepo(genericDb),
|
|
69
|
+
organizeJobRequest: new OrganizeJobRequestRepo(genericDb),
|
|
70
|
+
placementMove: new MessagePlacementMoveRepo(genericDb),
|
|
71
|
+
flagPush: new MessageFlagPushRepo(genericDb),
|
|
72
|
+
filter: new FilterRepo(genericDb),
|
|
73
|
+
filterAnchor: new FilterAnchorRepo(genericDb),
|
|
74
|
+
label: new LabelRepo(genericDb),
|
|
75
|
+
messageLabel: new MessageLabelRepo(genericDb),
|
|
76
|
+
unitOfWork: new DrizzleUnitOfWork(messageDataDb),
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
return createRemitClient({ repositories, ...buildSharedDeps() });
|
|
80
|
+
};
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
IAccountConfigRepository,
|
|
3
|
+
IAccountExportRequestRepository,
|
|
4
|
+
IAccountRepository,
|
|
5
|
+
IAccountSettingRepository,
|
|
6
|
+
IAddressRepository,
|
|
7
|
+
IEnvelopeRepository,
|
|
8
|
+
IFilterAnchorRepository,
|
|
9
|
+
IFilterRepository,
|
|
10
|
+
ILabelRepository,
|
|
11
|
+
IMailboxLockRepository,
|
|
12
|
+
IMailboxRepository,
|
|
13
|
+
IMailboxSpecialUseRepository,
|
|
14
|
+
IMessageFlagPushRepository,
|
|
15
|
+
IMessageFlagRepository,
|
|
16
|
+
IMessageLabelRepository,
|
|
17
|
+
IMessagePlacementMoveRepository,
|
|
18
|
+
IMessageRepository,
|
|
19
|
+
IOrganizeJobRequestRepository,
|
|
20
|
+
IOutboxMessageRepository,
|
|
21
|
+
IThreadMessageRepository,
|
|
22
|
+
IUnitOfWork,
|
|
23
|
+
} from "@remit/data-ports";
|
|
24
|
+
import { logger } from "@remit/logger-lambda";
|
|
25
|
+
import {
|
|
26
|
+
BodySyncQueueService,
|
|
27
|
+
BodySyncService,
|
|
28
|
+
createConnection,
|
|
29
|
+
FlagPushService,
|
|
30
|
+
FlagQueueService,
|
|
31
|
+
type IImapConnection,
|
|
32
|
+
MailboxQueueService,
|
|
33
|
+
MessageMoveService,
|
|
34
|
+
OutboxQueueService,
|
|
35
|
+
} from "@remit/mailbox-service";
|
|
36
|
+
import { createSearchService, type SearchService } from "@remit/search-service";
|
|
37
|
+
import {
|
|
38
|
+
buildEmbeddingServiceFromEnv,
|
|
39
|
+
buildVectorStoreFromEnv,
|
|
40
|
+
} from "@remit/search-service/from-env";
|
|
41
|
+
import {
|
|
42
|
+
createCachedDataKeyProvider,
|
|
43
|
+
createKmsDataKeyProvider,
|
|
44
|
+
createSecretsService,
|
|
45
|
+
deserializeEncryptedPayload,
|
|
46
|
+
FAKE_KMS_KEY_ID,
|
|
47
|
+
type SecretsService,
|
|
48
|
+
} from "@remit/secrets-service";
|
|
49
|
+
import type { StorageService } from "@remit/storage-service";
|
|
50
|
+
import { createStorageService } from "@remit/storage-service/s3";
|
|
51
|
+
import { env } from "expect-env";
|
|
52
|
+
|
|
53
|
+
export interface ConnectionScope {
|
|
54
|
+
getConnection: () => Promise<IImapConnection>;
|
|
55
|
+
disconnect: () => Promise<void>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface RemitClient {
|
|
59
|
+
// Data repositories (satisfied by ElectroDB services or Drizzle repos)
|
|
60
|
+
accountConfig: IAccountConfigRepository;
|
|
61
|
+
account: IAccountRepository;
|
|
62
|
+
accountSetting: IAccountSettingRepository;
|
|
63
|
+
address: IAddressRepository;
|
|
64
|
+
mailbox: IMailboxRepository;
|
|
65
|
+
mailboxSpecialUse: IMailboxSpecialUseRepository;
|
|
66
|
+
mailboxLock: IMailboxLockRepository;
|
|
67
|
+
message: IMessageRepository;
|
|
68
|
+
messageFlag: IMessageFlagRepository;
|
|
69
|
+
outboxMessage: IOutboxMessageRepository;
|
|
70
|
+
threadMessage: IThreadMessageRepository;
|
|
71
|
+
envelope: IEnvelopeRepository;
|
|
72
|
+
accountExportRequest: IAccountExportRequestRepository;
|
|
73
|
+
|
|
74
|
+
// Smart Organize back-apply job (RFC 034, #1278). Present on both backends,
|
|
75
|
+
// modeled on accountExportRequest — a Pending row the account-fanout worker
|
|
76
|
+
// picks up and drives to Complete/Failed. Never a Filter.
|
|
77
|
+
organizeJobRequest: IOrganizeJobRequestRepository;
|
|
78
|
+
|
|
79
|
+
// Smart Organize (RFC 034, epic #1280). Present on both backends
|
|
80
|
+
// (`FilterService`/`LabelService`/… on DynamoDB, `FilterRepo`/`LabelRepo`/…
|
|
81
|
+
// on Postgres) so the matching pipeline and filter CRUD run unchanged on
|
|
82
|
+
// either. The Postgres side has no TTL reaper for expired Temporary filters;
|
|
83
|
+
// match-time correctness gates on `expiresAt` (RFC 034 Decision 1.1), never
|
|
84
|
+
// on the row still existing, so the missing reaper is housekeeping only.
|
|
85
|
+
filter: IFilterRepository;
|
|
86
|
+
filterAnchor: IFilterAnchorRepository;
|
|
87
|
+
label: ILabelRepository;
|
|
88
|
+
messageLabel: IMessageLabelRepository;
|
|
89
|
+
|
|
90
|
+
// Atomic write set for a message save. Present on Postgres (real
|
|
91
|
+
// transaction); absent on DynamoDB, where callers fall back to per-repo
|
|
92
|
+
// writes with that backend's own (non-transactional) guarantees.
|
|
93
|
+
unitOfWork?: IUnitOfWork;
|
|
94
|
+
|
|
95
|
+
// Storage service
|
|
96
|
+
storage: StorageService;
|
|
97
|
+
|
|
98
|
+
// Search service (semantic vector search)
|
|
99
|
+
search: SearchService;
|
|
100
|
+
|
|
101
|
+
// Secrets service (KMS encryption)
|
|
102
|
+
secrets: SecretsService;
|
|
103
|
+
|
|
104
|
+
// Body sync service (on-demand IMAP body fetch)
|
|
105
|
+
bodySync: BodySyncService;
|
|
106
|
+
|
|
107
|
+
// Re-arms the SYNC_MESSAGE_BODY cue when a read-path body fetch finds the
|
|
108
|
+
// storage object missing. Absent when SQS_QUEUE_URL_BODY is not configured
|
|
109
|
+
// (the deployed API Lambda does not carry the body queue today).
|
|
110
|
+
bodySyncQueue?: BodySyncQueueService;
|
|
111
|
+
|
|
112
|
+
// Pending placement-move markers (issue #1271). Present on both backends
|
|
113
|
+
// (`MessagePlacementMoveService` on DynamoDB, `MessagePlacementMoveRepo` on
|
|
114
|
+
// Postgres) so a caller never needs to guess which backend is active. Used
|
|
115
|
+
// for read-time count prediction (epic #1281 invariant 4) and by the
|
|
116
|
+
// account-worker cascade delete. Written by the imap-worker's bulk
|
|
117
|
+
// body-sync path through this `placementMove`, so the placement producer
|
|
118
|
+
// runs on whatever backend is active — DynamoDB, Postgres, or SQLite.
|
|
119
|
+
placementMove: IMessagePlacementMoveRepository;
|
|
120
|
+
|
|
121
|
+
// Pending flag-push markers (issue #1273). Present on both backends
|
|
122
|
+
// (`MessageFlagPushService` on DynamoDB, `MessageFlagPushRepo` on
|
|
123
|
+
// Postgres). Used for read-time unseenCount prediction (epic #1281
|
|
124
|
+
// invariant 4), by the account-worker cascade delete, and by the periodic
|
|
125
|
+
// per-mailbox sync tick to re-arm a marker stuck `pending`. Unlike
|
|
126
|
+
// `placementMove`, this one IS written on both backends — `flagQueue`
|
|
127
|
+
// below writes through it regardless of `DATA_BACKEND`.
|
|
128
|
+
flagPush: IMessageFlagPushRepository;
|
|
129
|
+
|
|
130
|
+
// Queue services (writes with IMAP sync)
|
|
131
|
+
flagQueue: FlagQueueService;
|
|
132
|
+
mailboxQueue: MailboxQueueService;
|
|
133
|
+
messageMove: MessageMoveService;
|
|
134
|
+
outboxQueue: OutboxQueueService;
|
|
135
|
+
|
|
136
|
+
// Helper to create IMAP connection scope from accountId
|
|
137
|
+
createConnectionScope: (accountId: string) => Promise<ConnectionScope>;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface RemitClientRepositories {
|
|
141
|
+
accountConfig: IAccountConfigRepository;
|
|
142
|
+
account: IAccountRepository;
|
|
143
|
+
accountSetting: IAccountSettingRepository;
|
|
144
|
+
address: IAddressRepository;
|
|
145
|
+
mailbox: IMailboxRepository;
|
|
146
|
+
mailboxSpecialUse: IMailboxSpecialUseRepository;
|
|
147
|
+
mailboxLock: IMailboxLockRepository;
|
|
148
|
+
message: IMessageRepository;
|
|
149
|
+
messageFlag: IMessageFlagRepository;
|
|
150
|
+
outboxMessage: IOutboxMessageRepository;
|
|
151
|
+
threadMessage: IThreadMessageRepository;
|
|
152
|
+
envelope: IEnvelopeRepository;
|
|
153
|
+
accountExportRequest: IAccountExportRequestRepository;
|
|
154
|
+
organizeJobRequest: IOrganizeJobRequestRepository;
|
|
155
|
+
placementMove: IMessagePlacementMoveRepository;
|
|
156
|
+
flagPush: IMessageFlagPushRepository;
|
|
157
|
+
filter: IFilterRepository;
|
|
158
|
+
filterAnchor: IFilterAnchorRepository;
|
|
159
|
+
label: ILabelRepository;
|
|
160
|
+
messageLabel: IMessageLabelRepository;
|
|
161
|
+
unitOfWork?: IUnitOfWork;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface RemitClientSharedDeps {
|
|
165
|
+
storage: StorageService;
|
|
166
|
+
search: SearchService;
|
|
167
|
+
secrets: SecretsService;
|
|
168
|
+
sqsQueueUrl: string;
|
|
169
|
+
sqsSmtpQueueUrl: string;
|
|
170
|
+
bodySyncQueue?: BodySyncQueueService;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface RemitClientDeps extends RemitClientSharedDeps {
|
|
174
|
+
repositories: RemitClientRepositories;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const buildConnectionScope =
|
|
178
|
+
(accountService: IAccountRepository, secretsService: SecretsService) =>
|
|
179
|
+
async (accountId: string): Promise<ConnectionScope> => {
|
|
180
|
+
const account = await accountService.get(accountId);
|
|
181
|
+
if (!account.passwordHash) {
|
|
182
|
+
throw new Error(
|
|
183
|
+
`Account ${accountId} has no passwordHash — OAuth accounts cannot use this connection path`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
const password = await secretsService.decrypt(
|
|
187
|
+
deserializeEncryptedPayload(JSON.parse(account.passwordHash)),
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
let connection: IImapConnection | null = null;
|
|
191
|
+
let connectPromise: Promise<IImapConnection> | null = null;
|
|
192
|
+
|
|
193
|
+
const getConnection = async (): Promise<IImapConnection> => {
|
|
194
|
+
if (connectPromise) {
|
|
195
|
+
return connectPromise;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const conn = createConnection({
|
|
199
|
+
user: account.username,
|
|
200
|
+
credentials: { kind: "password", password },
|
|
201
|
+
host: account.imapHost,
|
|
202
|
+
port: account.imapPort,
|
|
203
|
+
tls: account.imapTls,
|
|
204
|
+
});
|
|
205
|
+
connection = conn;
|
|
206
|
+
connectPromise = conn.connect().then(() => conn);
|
|
207
|
+
|
|
208
|
+
return connectPromise;
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const disconnect = async (): Promise<void> => {
|
|
212
|
+
if (connection) {
|
|
213
|
+
await connection.disconnect();
|
|
214
|
+
connection = null;
|
|
215
|
+
connectPromise = null;
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
return { getConnection, disconnect };
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
// The vector store and embedder are selected from the environment by the shared
|
|
223
|
+
// builders: S3 Vectors + Bedrock Titan in production, persistent sqlite-vec +
|
|
224
|
+
// Transformers.js when the LOCAL_* / local flags are set (local dev), and the
|
|
225
|
+
// in-memory store + deterministic embedder otherwise (tests).
|
|
226
|
+
const buildSearchService = (): SearchService => {
|
|
227
|
+
// Build the embedder first so we can pass its dimension count to the
|
|
228
|
+
// sqlite-vec store — the vec0 table dimension must match the embedder.
|
|
229
|
+
const embedder = buildEmbeddingServiceFromEnv();
|
|
230
|
+
return createSearchService({
|
|
231
|
+
store: buildVectorStoreFromEnv(embedder.dimensions),
|
|
232
|
+
embedder,
|
|
233
|
+
});
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
// The body queue lives on the worker infra; the API only knows it locally
|
|
237
|
+
// (dev / e2e set SQS_QUEUE_URL_BODY). When unset the read path still returns a
|
|
238
|
+
// retryable 202 — it just cannot re-arm the cue itself.
|
|
239
|
+
const buildBodySyncQueue = (): BodySyncQueueService | undefined => {
|
|
240
|
+
const bodyQueueUrl = process.env.SQS_QUEUE_URL_BODY;
|
|
241
|
+
if (!bodyQueueUrl) return undefined;
|
|
242
|
+
return new BodySyncQueueService({ sqsQueueUrl: bodyQueueUrl, logger });
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
// Everything a RemitClient needs that is chosen from the environment and does
|
|
246
|
+
// not vary by data backend: storage, search, secrets, the SQS queue URLs, and
|
|
247
|
+
// the optional body-sync queue. Each composition root builds its backend's
|
|
248
|
+
// repositories and merges them with this.
|
|
249
|
+
export const buildSharedDeps = (): RemitClientSharedDeps => {
|
|
250
|
+
const storage = createStorageService();
|
|
251
|
+
const search = buildSearchService();
|
|
252
|
+
const kmsKeyId = process.env.KMS_KEY_ID ?? FAKE_KMS_KEY_ID;
|
|
253
|
+
const dataKeyProvider = createCachedDataKeyProvider(
|
|
254
|
+
createKmsDataKeyProvider(kmsKeyId),
|
|
255
|
+
);
|
|
256
|
+
const secrets = createSecretsService(dataKeyProvider);
|
|
257
|
+
const sqsQueueUrl = env.SQS_QUEUE_URL;
|
|
258
|
+
const sqsSmtpQueueUrl = process.env.SQS_QUEUE_URL_SMTP ?? sqsQueueUrl;
|
|
259
|
+
return {
|
|
260
|
+
storage,
|
|
261
|
+
search,
|
|
262
|
+
secrets,
|
|
263
|
+
sqsQueueUrl,
|
|
264
|
+
sqsSmtpQueueUrl,
|
|
265
|
+
bodySyncQueue: buildBodySyncQueue(),
|
|
266
|
+
};
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
// Backend-neutral composition root: given repositories (from any data-ports
|
|
270
|
+
// implementation) and the shared services, wire the domain and queue services
|
|
271
|
+
// and assemble a RemitClient. Imports neither ElectroDB nor Drizzle — the
|
|
272
|
+
// caller chooses the backend.
|
|
273
|
+
export const createRemitClient = (deps: RemitClientDeps): RemitClient => {
|
|
274
|
+
const {
|
|
275
|
+
repositories,
|
|
276
|
+
storage,
|
|
277
|
+
search,
|
|
278
|
+
secrets,
|
|
279
|
+
sqsQueueUrl,
|
|
280
|
+
sqsSmtpQueueUrl,
|
|
281
|
+
bodySyncQueue,
|
|
282
|
+
} = deps;
|
|
283
|
+
|
|
284
|
+
const bodySync = new BodySyncService(
|
|
285
|
+
repositories.message,
|
|
286
|
+
storage,
|
|
287
|
+
repositories.threadMessage,
|
|
288
|
+
repositories.address,
|
|
289
|
+
repositories.envelope,
|
|
290
|
+
logger,
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
const flagPushService = new FlagPushService({
|
|
294
|
+
markerService: repositories.flagPush,
|
|
295
|
+
sqsQueueUrl,
|
|
296
|
+
logger,
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
accountConfig: repositories.accountConfig,
|
|
301
|
+
account: repositories.account,
|
|
302
|
+
accountSetting: repositories.accountSetting,
|
|
303
|
+
address: repositories.address,
|
|
304
|
+
mailbox: repositories.mailbox,
|
|
305
|
+
mailboxSpecialUse: repositories.mailboxSpecialUse,
|
|
306
|
+
mailboxLock: repositories.mailboxLock,
|
|
307
|
+
message: repositories.message,
|
|
308
|
+
messageFlag: repositories.messageFlag,
|
|
309
|
+
outboxMessage: repositories.outboxMessage,
|
|
310
|
+
threadMessage: repositories.threadMessage,
|
|
311
|
+
envelope: repositories.envelope,
|
|
312
|
+
accountExportRequest: repositories.accountExportRequest,
|
|
313
|
+
organizeJobRequest: repositories.organizeJobRequest,
|
|
314
|
+
filter: repositories.filter,
|
|
315
|
+
filterAnchor: repositories.filterAnchor,
|
|
316
|
+
label: repositories.label,
|
|
317
|
+
messageLabel: repositories.messageLabel,
|
|
318
|
+
unitOfWork: repositories.unitOfWork,
|
|
319
|
+
|
|
320
|
+
storage,
|
|
321
|
+
search,
|
|
322
|
+
secrets,
|
|
323
|
+
|
|
324
|
+
bodySync,
|
|
325
|
+
bodySyncQueue,
|
|
326
|
+
placementMove: repositories.placementMove,
|
|
327
|
+
flagPush: repositories.flagPush,
|
|
328
|
+
|
|
329
|
+
flagQueue: new FlagQueueService({
|
|
330
|
+
messageFlagService: repositories.messageFlag,
|
|
331
|
+
messageService: repositories.message,
|
|
332
|
+
threadMessageService: repositories.threadMessage,
|
|
333
|
+
flagPushService,
|
|
334
|
+
logger,
|
|
335
|
+
}),
|
|
336
|
+
mailboxQueue: new MailboxQueueService({
|
|
337
|
+
mailboxService: repositories.mailbox,
|
|
338
|
+
sqsQueueUrl,
|
|
339
|
+
logger,
|
|
340
|
+
}),
|
|
341
|
+
messageMove: new MessageMoveService({
|
|
342
|
+
messageService: repositories.message,
|
|
343
|
+
mailboxService: repositories.mailbox,
|
|
344
|
+
mailboxSpecialUseService: repositories.mailboxSpecialUse,
|
|
345
|
+
threadMessageService: repositories.threadMessage,
|
|
346
|
+
sqsQueueUrl,
|
|
347
|
+
logger,
|
|
348
|
+
}),
|
|
349
|
+
outboxQueue: new OutboxQueueService({
|
|
350
|
+
outboxMessageService: repositories.outboxMessage,
|
|
351
|
+
accountService: repositories.account,
|
|
352
|
+
sqsSmtpQueueUrl,
|
|
353
|
+
logger,
|
|
354
|
+
}),
|
|
355
|
+
|
|
356
|
+
createConnectionScope: buildConnectionScope(repositories.account, secrets),
|
|
357
|
+
};
|
|
358
|
+
};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { afterEach, test } from "node:test";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { _resetForTest, getClient, type RemitClient } from "./dynamodb.js";
|
|
6
|
+
|
|
7
|
+
// The DynamoDB composition module is stripped from the open-core tree; its
|
|
8
|
+
// no-regression guard runs where the module ships and skips where it does not.
|
|
9
|
+
const hasDynamoComposition = existsSync(
|
|
10
|
+
fileURLToPath(new URL("./compose-dynamodb.ts", import.meta.url)),
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
const REQUIRED_KEYS: ReadonlyArray<keyof RemitClient> = [
|
|
14
|
+
"accountConfig",
|
|
15
|
+
"account",
|
|
16
|
+
"accountSetting",
|
|
17
|
+
"address",
|
|
18
|
+
"mailbox",
|
|
19
|
+
"mailboxSpecialUse",
|
|
20
|
+
"message",
|
|
21
|
+
"messageFlag",
|
|
22
|
+
"outboxMessage",
|
|
23
|
+
"threadMessage",
|
|
24
|
+
"envelope",
|
|
25
|
+
"accountExportRequest",
|
|
26
|
+
"storage",
|
|
27
|
+
"search",
|
|
28
|
+
"secrets",
|
|
29
|
+
"bodySync",
|
|
30
|
+
"flagQueue",
|
|
31
|
+
"mailboxQueue",
|
|
32
|
+
"messageMove",
|
|
33
|
+
"outboxQueue",
|
|
34
|
+
"createConnectionScope",
|
|
35
|
+
] as const;
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
_resetForTest();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("getClient() with DATA_BACKEND=postgres constructs all services without throwing", async () => {
|
|
42
|
+
const savedBackend = process.env.DATA_BACKEND;
|
|
43
|
+
const savedPgUrl = process.env.PG_CONNECTION_URL;
|
|
44
|
+
|
|
45
|
+
process.env.DATA_BACKEND = "postgres";
|
|
46
|
+
process.env.PG_CONNECTION_URL =
|
|
47
|
+
"postgresql://remit:remit@localhost:5432/remit_test";
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const client = await getClient();
|
|
51
|
+
|
|
52
|
+
for (const key of REQUIRED_KEYS) {
|
|
53
|
+
assert.ok(
|
|
54
|
+
client[key] !== undefined && client[key] !== null,
|
|
55
|
+
`RemitClient.${key} must be defined on the postgres path`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
assert.equal(typeof client.createConnectionScope, "function");
|
|
60
|
+
} finally {
|
|
61
|
+
_resetForTest();
|
|
62
|
+
if (savedBackend === undefined) {
|
|
63
|
+
delete process.env.DATA_BACKEND;
|
|
64
|
+
} else {
|
|
65
|
+
process.env.DATA_BACKEND = savedBackend;
|
|
66
|
+
}
|
|
67
|
+
if (savedPgUrl === undefined) {
|
|
68
|
+
delete process.env.PG_CONNECTION_URL;
|
|
69
|
+
} else {
|
|
70
|
+
process.env.PG_CONNECTION_URL = savedPgUrl;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test(
|
|
76
|
+
"getClient() without DATA_BACKEND constructs all services without throwing (DynamoDB path, no regression)",
|
|
77
|
+
{ skip: !hasDynamoComposition },
|
|
78
|
+
async () => {
|
|
79
|
+
const savedBackend = process.env.DATA_BACKEND;
|
|
80
|
+
delete process.env.DATA_BACKEND;
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const client = await getClient();
|
|
84
|
+
|
|
85
|
+
for (const key of REQUIRED_KEYS) {
|
|
86
|
+
assert.ok(
|
|
87
|
+
client[key] !== undefined && client[key] !== null,
|
|
88
|
+
`RemitClient.${key} must be defined on the DynamoDB path`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
assert.equal(typeof client.createConnectionScope, "function");
|
|
93
|
+
} finally {
|
|
94
|
+
_resetForTest();
|
|
95
|
+
if (savedBackend !== undefined) {
|
|
96
|
+
process.env.DATA_BACKEND = savedBackend;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
);
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { RemitClient } from "./create-remit-client.js";
|
|
2
|
+
|
|
3
|
+
export type {
|
|
4
|
+
ConnectionScope,
|
|
5
|
+
RemitClient,
|
|
6
|
+
} from "./create-remit-client.js";
|
|
7
|
+
|
|
8
|
+
// The DynamoDB composition module is stripped from the open-core export (it
|
|
9
|
+
// imports the closed electrodb-service). The `as string` specifier keeps
|
|
10
|
+
// tsgo from resolving it there — where the file is absent — while this named
|
|
11
|
+
// contract keeps the call site typed rather than `any`. The relational tree
|
|
12
|
+
// never takes this branch; the DynamoDB deploy, where the module is present,
|
|
13
|
+
// resolves it at runtime.
|
|
14
|
+
type ComposeDynamoDBModule = { buildDynamoDBClient: () => RemitClient };
|
|
15
|
+
|
|
16
|
+
let clientPromise: Promise<RemitClient> | null = null;
|
|
17
|
+
|
|
18
|
+
// The API process and the imap-worker share this composition root. Each backend
|
|
19
|
+
// is loaded through a dynamic import of its own composition module, so the
|
|
20
|
+
// module that a given deploy never runs never enters its graph: `@remit/remit-
|
|
21
|
+
// drizzle-service`/`drizzle-orm` stay out of a Lambda bundle (both `external`
|
|
22
|
+
// for the Lambda esbuild build; `remit-lambda-bundles.test.ts` enforces this),
|
|
23
|
+
// and the DynamoDB composition — which imports the closed `@remit/remit-
|
|
24
|
+
// electrodb-service` — stays out of the relational (open-core) tree, where its
|
|
25
|
+
// module is not shipped at all. esbuild still bundles the DynamoDB path into
|
|
26
|
+
// the Lambda because `./compose-dynamodb.js` is a relative import, not an
|
|
27
|
+
// `external`.
|
|
28
|
+
export const getClient = (): Promise<RemitClient> => {
|
|
29
|
+
if (!clientPromise) {
|
|
30
|
+
if (process.env.DATA_BACKEND === "postgres") {
|
|
31
|
+
clientPromise = import("./compose-postgres.js").then((m) =>
|
|
32
|
+
m.buildPostgresClient(),
|
|
33
|
+
);
|
|
34
|
+
} else if (process.env.DATA_BACKEND === "sqlite") {
|
|
35
|
+
clientPromise = import("./compose-sqlite.js").then((m) =>
|
|
36
|
+
m.buildSqliteClient(),
|
|
37
|
+
);
|
|
38
|
+
} else {
|
|
39
|
+
clientPromise = (
|
|
40
|
+
import(
|
|
41
|
+
"./compose-dynamodb.js" as string
|
|
42
|
+
) as Promise<ComposeDynamoDBModule>
|
|
43
|
+
).then((m) => m.buildDynamoDBClient());
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return clientPromise;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** Reset the singleton — test use only. */
|
|
51
|
+
export const _resetForTest = (): void => {
|
|
52
|
+
clientPromise = null;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** Inject a (usually partial) client — test use only. */
|
|
56
|
+
export const _setClientForTest = (override: RemitClient): void => {
|
|
57
|
+
clientPromise = Promise.resolve(override);
|
|
58
|
+
};
|