nolo-cli 0.1.13 → 0.1.14
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/README.md +9 -2
- package/agent-runtime/hostAdapter.ts +53 -0
- package/agent-runtime/index.ts +28 -0
- package/agent-runtime/localLoop.ts +62 -0
- package/agent-runtime/runtimeDecision.ts +70 -0
- package/agent-runtime/types.ts +87 -0
- package/agentRuntimeCommands.ts +139 -22
- package/agentRuntimeLocal.ts +7 -0
- package/ai/agent/_executeModel.ts +118 -0
- package/ai/agent/agentSlice.ts +544 -1
- package/ai/agent/appWorkingMemory.ts +126 -0
- package/ai/agent/avatarUtils.ts +24 -0
- package/ai/agent/buildEditingContext.ts +373 -0
- package/ai/agent/buildSystemPrompt.ts +532 -0
- package/ai/agent/cleanAgentMessages.ts +140 -0
- package/ai/agent/cliChatClient.ts +119 -0
- package/ai/agent/contextCompiler.ts +107 -0
- package/ai/agent/contextLayerContract.ts +44 -0
- package/ai/agent/createAgentSchema.ts +234 -0
- package/ai/agent/executeToolCall.ts +58 -0
- package/ai/agent/fetchAgentContexts.ts +42 -0
- package/ai/agent/generatePrompt.ts +3 -0
- package/ai/agent/getFullChatContextKeys.ts +168 -0
- package/ai/agent/hooks/fetchPublicAgents.ts +133 -0
- package/ai/agent/hooks/useAgentConfig.ts +61 -0
- package/ai/agent/hooks/useAgentDialog.ts +35 -0
- package/ai/agent/hooks/useAgentFormValidation.ts +202 -0
- package/ai/agent/hooks/usePublicAgents.ts +473 -0
- package/ai/agent/persistMessageWithFixedId.ts +37 -0
- package/ai/agent/planSlice.ts +259 -0
- package/ai/agent/referenceUtils.ts +229 -0
- package/ai/agent/runAgentBackground.ts +238 -0
- package/ai/agent/runAgentClientLoop.ts +138 -0
- package/ai/agent/runtimeGuidance.ts +97 -0
- package/ai/agent/runtimeServerBase.ts +37 -0
- package/ai/agent/server/fetchPublicAgents.ts +128 -0
- package/ai/agent/startParallelAgentStreams.ts +424 -0
- package/ai/agent/startupProtocol.ts +53 -0
- package/ai/agent/streamAgentChatTurn.ts +1299 -0
- package/ai/agent/streamAgentChatTurnUtils.ts +738 -0
- package/ai/agent/types.ts +71 -0
- package/ai/agent/utils/imageOutput.ts +39 -0
- package/ai/agent/utils/publicImageAgentMode.ts +26 -0
- package/ai/agent/utils/sortUtils.ts +250 -0
- package/ai/agent/web/referencePickerUtils.ts +146 -0
- package/ai/ai.locale.ts +1083 -0
- package/ai/chat/accumulateToolCallChunks.ts +95 -0
- package/ai/chat/fetchUtils.native.ts +276 -0
- package/ai/chat/fetchUtils.ts +153 -0
- package/ai/chat/inlineImageUrlsForCustomProvider.ts +117 -0
- package/ai/chat/parseApiError.ts +64 -0
- package/ai/chat/parseMultilineSSE.ts +95 -0
- package/ai/chat/sendOpenAICompletionsRequest.native.ts +682 -0
- package/ai/chat/sendOpenAICompletionsRequest.ts +712 -0
- package/ai/chat/sendOpenAIResponseRequest.ts +512 -0
- package/ai/chat/shouldUseServerProxy.ts +18 -0
- package/ai/chat/sseClient.native.ts +91 -0
- package/ai/chat/sseClient.ts +67 -0
- package/ai/chat/streamReader.native.ts +31 -0
- package/ai/chat/streamReader.ts +62 -0
- package/ai/chat/updateTotalUsage.ts +72 -0
- package/ai/context/buildReferenceContext.ts +437 -0
- package/ai/context/calculateContextUsage.ts +133 -0
- package/ai/context/retention.ts +165 -0
- package/ai/context/tokenUtils.ts +78 -0
- package/ai/index.ts +1 -1
- package/ai/llm/agentCapabilities.ts +74 -0
- package/ai/llm/calculateGeminiImageTokens.ts +57 -0
- package/ai/llm/deepinfra.ts +28 -0
- package/ai/llm/fireworks.ts +68 -0
- package/ai/llm/generateRequestBody.ts +165 -0
- package/ai/llm/getModelContextWindow.ts +84 -0
- package/ai/llm/getNoloKey.ts +37 -0
- package/ai/llm/getPricing.ts +232 -0
- package/ai/llm/hooks/useModelPricing.ts +75 -0
- package/ai/llm/imagePricing.ts +66 -0
- package/ai/llm/isResponseAPIModel.ts +13 -0
- package/ai/llm/kimi.ts +18 -0
- package/ai/llm/mimo.ts +71 -0
- package/ai/llm/mistral.ts +22 -0
- package/ai/llm/modelAvatar.ts +427 -0
- package/ai/llm/models.ts +45 -0
- package/ai/llm/openrouterModels.ts +141 -0
- package/ai/llm/providers.ts +307 -0
- package/ai/llm/reasoningModels.ts +28 -0
- package/ai/llm/types.ts +59 -0
- package/ai/llm/usageRequestOptions.ts +59 -0
- package/ai/memory/capture.ts +148 -0
- package/ai/memory/consolidate.ts +104 -0
- package/ai/memory/delete.ts +147 -0
- package/ai/memory/overlay.ts +84 -0
- package/ai/memory/query.ts +38 -0
- package/ai/memory/queryShared.ts +160 -0
- package/ai/memory/rank.ts +105 -0
- package/ai/memory/recentRelationshipRecap.ts +247 -0
- package/ai/memory/remember.ts +167 -0
- package/ai/memory/runtime.ts +76 -0
- package/ai/memory/store.ts +20 -0
- package/ai/memory/storeShared.ts +76 -0
- package/ai/memory/types.ts +46 -0
- package/ai/memory/understanding.ts +349 -0
- package/ai/memory/understandingGreeting.ts +264 -0
- package/ai/messages/type.ts +20 -0
- package/ai/policy/personalizationDialog.ts +333 -0
- package/ai/policy/runtimePolicy.ts +440 -0
- package/ai/policy/selfUpdateFields.ts +48 -0
- package/ai/policy/types.ts +64 -0
- package/ai/skills/referenceRuntime.ts +274 -0
- package/ai/skills/skillDiagnostics.ts +251 -0
- package/ai/skills/skillDocBuilder.ts +139 -0
- package/ai/skills/skillDocProtocol.ts +434 -0
- package/ai/skills/skillReferenceSummary.ts +63 -0
- package/ai/skills/skillSummaryMarker.ts +26 -0
- package/ai/token/calculatePrice.ts +546 -0
- package/ai/token/db.ts +98 -0
- package/ai/token/externalToolCost.ts +321 -0
- package/ai/token/hooks/useRecords.ts +65 -0
- package/ai/token/missingUsageEstimate.ts +42 -0
- package/ai/token/modelUsageQuery.ts +252 -0
- package/ai/token/normalizeUsage.ts +84 -0
- package/ai/token/openaiImageGenerationUsage.ts +56 -0
- package/ai/token/prepareTokenUsageData.ts +88 -0
- package/ai/token/query.ts +88 -0
- package/ai/token/queryUserTokens.ts +59 -0
- package/ai/token/resolveBillingTarget.ts +52 -0
- package/ai/token/saveTokenRecord.ts +53 -0
- package/ai/token/serverDialogProjection.ts +78 -0
- package/ai/token/serverTokenWriter.ts +143 -0
- package/ai/token/stats.ts +21 -0
- package/ai/token/tokenThunks.ts +24 -0
- package/ai/token/types.ts +93 -0
- package/ai/tools/agent/agentTools.ts +176 -0
- package/ai/tools/agent/agentUpdateShared.ts +311 -0
- package/ai/tools/agent/callAgentTool.ts +139 -0
- package/ai/tools/agent/createAgentTool.ts +512 -0
- package/ai/tools/agent/createDialogTool.ts +69 -0
- package/ai/tools/agent/createSkillAgentTool.ts +62 -0
- package/ai/tools/agent/parallelBudget.ts +221 -0
- package/ai/tools/agent/presets/appBuilderPreset.ts +147 -0
- package/ai/tools/agent/runLlmTool.ts +96 -0
- package/ai/tools/agent/runStreamingAgentTool.ts +73 -0
- package/ai/tools/agent/skillAgentArgs.ts +106 -0
- package/ai/tools/agent/skillAgentPreset.ts +89 -0
- package/ai/tools/agent/streamParallelAgentsTool.ts +122 -0
- package/ai/tools/agent/updateAgentTool.ts +96 -0
- package/ai/tools/agent/updateSelfTool.ts +113 -0
- package/ai/tools/amazonProductScraperTool.ts +86 -0
- package/ai/tools/apifyActorClient.ts +45 -0
- package/ai/tools/appEditGuard.ts +372 -0
- package/ai/tools/appReadSnapshot.ts +153 -0
- package/ai/tools/appTools.ts +1549 -0
- package/ai/tools/applyEditTool.ts +256 -0
- package/ai/tools/applyLineEditsTool.ts +312 -0
- package/ai/tools/browserTools/click.ts +33 -0
- package/ai/tools/browserTools/closeSession.ts +29 -0
- package/ai/tools/browserTools/common.ts +27 -0
- package/ai/tools/browserTools/openSession.ts +48 -0
- package/ai/tools/browserTools/readContent.ts +38 -0
- package/ai/tools/browserTools/selectOption.ts +46 -0
- package/ai/tools/browserTools/typeText.ts +42 -0
- package/ai/tools/category/createCategoryTool.ts +66 -0
- package/ai/tools/category/queryContentsByCategoryTool.ts +69 -0
- package/ai/tools/category/updateContentCategoryTool.ts +75 -0
- package/ai/tools/cfBrowserTools.ts +319 -0
- package/ai/tools/cfSpeechToTextTool.ts +49 -0
- package/ai/tools/checkEnvTool.ts +65 -0
- package/ai/tools/cloudflareCrawlTool.ts +289 -0
- package/ai/tools/codeSearchTool.ts +111 -0
- package/ai/tools/codeTools.ts +101 -0
- package/ai/tools/createDocTool.ts +132 -0
- package/ai/tools/createPlanTool.ts +999 -0
- package/ai/tools/createSkillDocTool.ts +155 -0
- package/ai/tools/createWorkflowTool.ts +154 -0
- package/ai/tools/deepseekOcrTool.ts +34 -0
- package/ai/tools/delayTool.ts +31 -0
- package/ai/tools/deleteSpacesTool.ts +325 -0
- package/ai/tools/deleteSpacesToolModel.ts +159 -0
- package/ai/tools/devReloadUtils.ts +29 -0
- package/ai/tools/dialogMessageSearch.ts +137 -0
- package/ai/tools/doctorSkillTool.ts +72 -0
- package/ai/tools/ecommerceScraperTool.ts +86 -0
- package/ai/tools/emailTools.ts +549 -0
- package/ai/tools/evalSkillTool.ts +92 -0
- package/ai/tools/exaSearchTool.ts +64 -0
- package/ai/tools/execBashTool.ts +379 -0
- package/ai/tools/executeSqlTool.ts +192 -0
- package/ai/tools/fetchWebpageSupport.ts +309 -0
- package/ai/tools/fetchWebpageTool.ts +84 -0
- package/ai/tools/geminiImagePreviewTool.ts +361 -0
- package/ai/tools/generateDocxTool.ts +215 -0
- package/ai/tools/googleSearchScraperTool.ts +106 -0
- package/ai/tools/importDataTool.ts +133 -0
- package/ai/tools/importSkillTool.ts +162 -0
- package/ai/tools/index.ts +1927 -0
- package/ai/tools/listFilesTool.ts +82 -0
- package/ai/tools/listUserSpacesTool.ts +113 -0
- package/ai/tools/modelUsageTools.ts +199 -0
- package/ai/tools/olmOcrTool.ts +34 -0
- package/ai/tools/openaiImageTool.ts +267 -0
- package/ai/tools/prepareTools.ts +23 -0
- package/ai/tools/readDocTool.ts +84 -0
- package/ai/tools/readFileTool.ts +211 -0
- package/ai/tools/readTool.ts +163 -0
- package/ai/tools/readXPostTool.ts +233 -0
- package/ai/tools/rememberMemoryTool.ts +84 -0
- package/ai/tools/remotionVideoTool.ts +151 -0
- package/ai/tools/searchDialogMessagesTool.ts +222 -0
- package/ai/tools/searchRepoTool.ts +115 -0
- package/ai/tools/searchWorkspaceTool.ts +259 -0
- package/ai/tools/skillFollowup.ts +86 -0
- package/ai/tools/surfWeatherTool.ts +169 -0
- package/ai/tools/table/addTableRowTool.ts +217 -0
- package/ai/tools/table/createTableTool.ts +315 -0
- package/ai/tools/table/rowTools.ts +366 -0
- package/ai/tools/table/schemaTools.ts +244 -0
- package/ai/tools/table/shareTableTool.ts +148 -0
- package/ai/tools/table/toolShared.ts +129 -0
- package/ai/tools/toolApiClient.ts +198 -0
- package/ai/tools/toolNameAliases.ts +57 -0
- package/ai/tools/toolResultError.ts +42 -0
- package/ai/tools/toolRunSlice.ts +303 -0
- package/ai/tools/toolSchemaCompatibility.ts +53 -0
- package/ai/tools/toolVisibility.ts +4 -0
- package/ai/tools/types.ts +20 -0
- package/ai/tools/uiAskChoiceTool.ts +104 -0
- package/ai/tools/updateContentTitleTool.ts +84 -0
- package/ai/tools/updateDocTool.ts +105 -0
- package/ai/tools/updateUserPreferenceProfileTool.ts +145 -0
- package/ai/tools/whisperTool.ts +77 -0
- package/ai/tools/writeFileTool.ts +210 -0
- package/ai/tools/youtubeScraperTool.ts +116 -0
- package/ai/tools/ziweiChartTool.ts +678 -0
- package/ai/types.ts +55 -0
- package/ai/workflow/workflowExecutor.ts +323 -0
- package/ai/workflow/workflowSlice.ts +73 -0
- package/ai/workflow/workflowTypes.ts +106 -0
- package/client/agentRun.test.ts +240 -0
- package/client/agentRun.ts +182 -19
- package/client/compactDialog.test.ts +238 -0
- package/client/localRuntimeAdapter.test.ts +135 -0
- package/client/localRuntimeAdapter.ts +244 -0
- package/client/profileConfig.test.ts +40 -0
- package/client/streamingOutput.test.ts +22 -0
- package/client/streamingOutput.ts +38 -0
- package/commandRegistry.ts +9 -2
- package/connector-experimental/index.ts +5 -0
- package/database/actions/cacheMergedUserData.ts +64 -0
- package/database/actions/common.ts +242 -0
- package/database/actions/deleteFile.ts +40 -0
- package/database/actions/fetchUserData.ts +16 -0
- package/database/actions/fileContent.ts +125 -0
- package/database/actions/patch.ts +155 -0
- package/database/actions/read.ts +337 -0
- package/database/actions/readAndWait.ts +224 -0
- package/database/actions/readRequestManager.ts +120 -0
- package/database/actions/remove.ts +94 -0
- package/database/actions/replication.ts +366 -0
- package/database/actions/upload.ts +174 -0
- package/database/actions/upsert.ts +56 -0
- package/database/actions/write.ts +126 -0
- package/database/client/db.native.ts +73 -0
- package/database/client/db.ts +51 -0
- package/database/client/fetchUserData.ts +61 -0
- package/database/client/handleError.ts +19 -0
- package/database/client/queryRequest.ts +21 -0
- package/database/config.ts +21 -0
- package/database/dbActionThunks.ts +1 -0
- package/database/dbSlice.ts +149 -0
- package/database/email.ts +42 -0
- package/database/fileRing.ts +51 -0
- package/database/fileSharding.ts +70 -0
- package/database/fileStorage.native.ts +92 -0
- package/database/fileStorage.ts +232 -0
- package/database/fileUrl.ts +34 -0
- package/database/hooks/useUserData.ts +489 -0
- package/database/index.ts +1 -0
- package/database/keys.ts +765 -0
- package/database/queryPrefixes.ts +14 -0
- package/database/requests.ts +443 -0
- package/database/runtimeServerContext.ts +35 -0
- package/database/server/MemoryDB.ts +76 -0
- package/database/server/actorAccess.ts +76 -0
- package/database/server/agentDelegation.ts +124 -0
- package/database/server/coreDataOwnership.ts +13 -0
- package/database/server/coreDataProxy.ts +76 -0
- package/database/server/cybotReadonly.ts +18 -0
- package/database/server/dataHandlers.ts +111 -0
- package/database/server/db.ts +118 -0
- package/database/server/dbPath.ts +20 -0
- package/database/server/delete.ts +499 -0
- package/database/server/emailRepository.ts +1480 -0
- package/database/server/ensureDbOpen.ts +12 -0
- package/database/server/fileRead.ts +337 -0
- package/database/server/fileService.ts +436 -0
- package/database/server/handleTransaction.ts +86 -0
- package/database/server/patch.ts +282 -0
- package/database/server/query.ts +138 -0
- package/database/server/read.ts +325 -0
- package/database/server/resourceAccess.ts +211 -0
- package/database/server/routes.ts +110 -0
- package/database/server/spaceMemberAuthority.ts +67 -0
- package/database/server/upload.ts +159 -0
- package/database/server/write.ts +494 -0
- package/database/server/writeAuthority.ts +133 -0
- package/database/sqliteDb.ts +46 -0
- package/database/table/deleteTable.ts +120 -0
- package/database/tenantPlacement.ts +57 -0
- package/database/tombstones.ts +52 -0
- package/database/userDataLoadDecision.ts +17 -0
- package/database/userDataMerge.ts +95 -0
- package/database/userPreferenceRegister.ts +108 -0
- package/database/utils/dbPath.ts +47 -0
- package/database/utils/ulid.native.ts +6 -0
- package/database/utils/ulid.ts +1 -0
- package/index.ts +25 -15
- package/localRuntimeDb.ts +28 -0
- package/package.json +16 -4
- package/runtimeModeArgs.ts +33 -0
- package/tui/readlineWorkspace.ts +1 -0
- package/tui/session.ts +22 -0
|
@@ -0,0 +1,1480 @@
|
|
|
1
|
+
import type { ActorContext } from "auth/actor";
|
|
2
|
+
import { DataType } from "create/types";
|
|
3
|
+
import type {
|
|
4
|
+
EmailMailbox,
|
|
5
|
+
EmailParticipant,
|
|
6
|
+
EmailRecord,
|
|
7
|
+
EmailStatus,
|
|
8
|
+
} from "database/email";
|
|
9
|
+
import { emailKey } from "database/keys";
|
|
10
|
+
import type { ApiContext, ApiError } from "server/api/types";
|
|
11
|
+
import {
|
|
12
|
+
type EmailProvider,
|
|
13
|
+
type EmailProviderSendResult,
|
|
14
|
+
} from "server/email/provider";
|
|
15
|
+
import { getEmailProvider } from "server/email/providerRegistry";
|
|
16
|
+
import { authorizeActorRecordAccess } from "./actorAccess";
|
|
17
|
+
import { agentDelegationKey } from "./agentDelegation";
|
|
18
|
+
import serverDb from "./db";
|
|
19
|
+
import { resolveKeyOwnerId } from "./writeAuthority";
|
|
20
|
+
|
|
21
|
+
type CreateEmailRecordInput = {
|
|
22
|
+
ownerId: string;
|
|
23
|
+
ownerType?: EmailRecord["ownerType"];
|
|
24
|
+
tenantId?: string;
|
|
25
|
+
spaceId?: string | null;
|
|
26
|
+
mailbox?: EmailMailbox;
|
|
27
|
+
status?: EmailStatus;
|
|
28
|
+
from: EmailParticipant;
|
|
29
|
+
to?: EmailParticipant[];
|
|
30
|
+
cc?: EmailParticipant[];
|
|
31
|
+
bcc?: EmailParticipant[];
|
|
32
|
+
replyTo?: EmailParticipant[];
|
|
33
|
+
subject?: string;
|
|
34
|
+
text?: string;
|
|
35
|
+
html?: string;
|
|
36
|
+
messageId?: string;
|
|
37
|
+
threadId?: string;
|
|
38
|
+
inReplyTo?: string;
|
|
39
|
+
references?: string[];
|
|
40
|
+
tags?: string[];
|
|
41
|
+
meta?: Record<string, unknown>;
|
|
42
|
+
now?: string | number;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
type ListEmailsParams = {
|
|
46
|
+
ownerId?: string;
|
|
47
|
+
mailbox?: EmailMailbox;
|
|
48
|
+
status?: EmailStatus;
|
|
49
|
+
tag?: string;
|
|
50
|
+
limit?: number;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
type EmailKeyParams = {
|
|
54
|
+
dbKey: string;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
type UpdateEmailTagsParams = EmailKeyParams & {
|
|
58
|
+
tags: string[];
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
type SendEmailParams = {
|
|
62
|
+
agentId: string;
|
|
63
|
+
from: EmailParticipant;
|
|
64
|
+
to: EmailParticipant[];
|
|
65
|
+
cc?: EmailParticipant[];
|
|
66
|
+
bcc?: EmailParticipant[];
|
|
67
|
+
replyTo?: EmailParticipant[];
|
|
68
|
+
subject: string;
|
|
69
|
+
text?: string;
|
|
70
|
+
html?: string;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
type ReceiveInboundEmailResult = {
|
|
74
|
+
records: EmailRecord[];
|
|
75
|
+
existing: EmailRecord[];
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
type MirrorEmailToControlledInboxesParams = {
|
|
79
|
+
from: EmailParticipant;
|
|
80
|
+
to?: EmailParticipant[];
|
|
81
|
+
cc?: EmailParticipant[];
|
|
82
|
+
bcc?: EmailParticipant[];
|
|
83
|
+
replyTo?: EmailParticipant[];
|
|
84
|
+
subject?: string;
|
|
85
|
+
text?: string;
|
|
86
|
+
html?: string;
|
|
87
|
+
providerName: string;
|
|
88
|
+
providerMessageId?: string;
|
|
89
|
+
providerReceivedAt?: string | number;
|
|
90
|
+
rawHeaders?: unknown;
|
|
91
|
+
raw?: unknown;
|
|
92
|
+
meta?: Record<string, unknown>;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
type BindAgentEmailIdentityParams = {
|
|
96
|
+
agentId: string;
|
|
97
|
+
emailAddress: string;
|
|
98
|
+
provider?: string;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
type ProvisionAgentEmailIdentityParams = {
|
|
102
|
+
agentId: string;
|
|
103
|
+
purpose?: string;
|
|
104
|
+
localPart?: string;
|
|
105
|
+
domain?: string;
|
|
106
|
+
provider?: string;
|
|
107
|
+
makePrimary?: boolean;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
type AgentEmailIdentity = {
|
|
111
|
+
emailAddress: string;
|
|
112
|
+
provider?: string;
|
|
113
|
+
purpose?: string;
|
|
114
|
+
createdAt?: string | number;
|
|
115
|
+
verifiedAt?: string | number;
|
|
116
|
+
readinessStatus?: "created" | "warming" | "ready" | "failed_warmup";
|
|
117
|
+
ingressReadyAt?: string | number | null;
|
|
118
|
+
lastWarmupAt?: string | number | null;
|
|
119
|
+
lastWarmupError?: string | null;
|
|
120
|
+
disabledAt?: string | number | null;
|
|
121
|
+
source?: "bound" | "provisioned";
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const MAX_LIST_LIMIT = 200;
|
|
125
|
+
const DEFAULT_AGENT_EMAIL_PROVIDER = "cloudflare";
|
|
126
|
+
const DEFAULT_CLOUDFLARE_EMAIL_ROUTING_WORKER = "bun-nolo-agent-email-router";
|
|
127
|
+
const DEFAULT_CLOUDFLARE_EMAIL_ROUTE_READY_TIMEOUT_MS = 60000;
|
|
128
|
+
const DEFAULT_CLOUDFLARE_EMAIL_ROUTE_READY_POLL_MS = 2500;
|
|
129
|
+
|
|
130
|
+
const apiError = (code: ApiError["code"], message: string): ApiError => ({
|
|
131
|
+
code,
|
|
132
|
+
message,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const normalizeTags = (tags?: unknown): string[] => {
|
|
136
|
+
if (!Array.isArray(tags)) return [];
|
|
137
|
+
return Array.from(
|
|
138
|
+
new Set(
|
|
139
|
+
tags
|
|
140
|
+
.map((tag) => (typeof tag === "string" ? tag.trim() : ""))
|
|
141
|
+
.filter(Boolean)
|
|
142
|
+
)
|
|
143
|
+
);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const actorOwnerId = (actor?: ActorContext | null): string | null => {
|
|
147
|
+
if (!actor) return null;
|
|
148
|
+
if (actor.type === "user") return actor.userId;
|
|
149
|
+
return actor.principalUserId || null;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const requireActor = (context?: ApiContext): ActorContext => {
|
|
153
|
+
const actor = context?.actor;
|
|
154
|
+
if (!actor) throw apiError("unauthorized", "Authentication required");
|
|
155
|
+
return actor;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const assertEmailAccess = async (
|
|
159
|
+
actor: ActorContext,
|
|
160
|
+
action: "read" | "manage" | "write",
|
|
161
|
+
record: EmailRecord
|
|
162
|
+
) => {
|
|
163
|
+
const access = await authorizeActorRecordAccess({
|
|
164
|
+
action,
|
|
165
|
+
actor,
|
|
166
|
+
dbKey: record.dbKey,
|
|
167
|
+
record,
|
|
168
|
+
});
|
|
169
|
+
if (!access.allowed) {
|
|
170
|
+
throw apiError("forbidden", "You do not have access to this email");
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const loadEmailRecord = async (dbKey: string): Promise<EmailRecord> => {
|
|
175
|
+
if (!dbKey) throw apiError("invalid_input", "dbKey is required");
|
|
176
|
+
|
|
177
|
+
const record = await serverDb.get(dbKey).catch(() => null);
|
|
178
|
+
if (!record || record.type !== DataType.EMAIL || record.deletedAt) {
|
|
179
|
+
throw apiError("not_found", "Email not found");
|
|
180
|
+
}
|
|
181
|
+
return record as EmailRecord;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const normalizeEmailAddress = (value?: unknown): string =>
|
|
185
|
+
typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
186
|
+
|
|
187
|
+
const normalizeParticipants = (value?: unknown): EmailParticipant[] => {
|
|
188
|
+
if (!Array.isArray(value)) return [];
|
|
189
|
+
return value
|
|
190
|
+
.map((item) => {
|
|
191
|
+
if (!item || typeof item !== "object") return null;
|
|
192
|
+
const raw = item as Record<string, unknown>;
|
|
193
|
+
const email = normalizeEmailAddress(raw.email);
|
|
194
|
+
if (!email) return null;
|
|
195
|
+
const name =
|
|
196
|
+
typeof raw.name === "string" && raw.name.trim()
|
|
197
|
+
? raw.name.trim()
|
|
198
|
+
: undefined;
|
|
199
|
+
return { email, ...(name ? { name } : {}) };
|
|
200
|
+
})
|
|
201
|
+
.filter((item): item is EmailParticipant => Boolean(item));
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const normalizeEmailDomain = (value?: unknown): string =>
|
|
205
|
+
typeof value === "string"
|
|
206
|
+
? value.trim().toLowerCase().replace(/^@+/, "")
|
|
207
|
+
: "";
|
|
208
|
+
|
|
209
|
+
const normalizeLocalPart = (value?: unknown): string =>
|
|
210
|
+
typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
211
|
+
|
|
212
|
+
const normalizePurpose = (value?: unknown): string => {
|
|
213
|
+
if (typeof value !== "string") return "";
|
|
214
|
+
return value
|
|
215
|
+
.trim()
|
|
216
|
+
.toLowerCase()
|
|
217
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
218
|
+
.replace(/^-+|-+$/g, "")
|
|
219
|
+
.slice(0, 32);
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const hashToken = (value: string): string => {
|
|
223
|
+
let hash = 2166136261;
|
|
224
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
225
|
+
hash ^= value.charCodeAt(index);
|
|
226
|
+
hash = Math.imul(hash, 16777619);
|
|
227
|
+
}
|
|
228
|
+
return (hash >>> 0).toString(36).padStart(6, "0").slice(0, 8);
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const PROVISIONED_EMAIL_FIRST_NAMES = [
|
|
232
|
+
"ava",
|
|
233
|
+
"clara",
|
|
234
|
+
"ella",
|
|
235
|
+
"grace",
|
|
236
|
+
"iris",
|
|
237
|
+
"julia",
|
|
238
|
+
"lena",
|
|
239
|
+
"maya",
|
|
240
|
+
"nina",
|
|
241
|
+
"olive",
|
|
242
|
+
"sara",
|
|
243
|
+
"tessa",
|
|
244
|
+
"leo",
|
|
245
|
+
"milo",
|
|
246
|
+
"owen",
|
|
247
|
+
"theo",
|
|
248
|
+
] as const;
|
|
249
|
+
|
|
250
|
+
const PROVISIONED_EMAIL_LAST_NAMES = [
|
|
251
|
+
"bennett",
|
|
252
|
+
"brooks",
|
|
253
|
+
"carter",
|
|
254
|
+
"ellis",
|
|
255
|
+
"foster",
|
|
256
|
+
"hayes",
|
|
257
|
+
"jensen",
|
|
258
|
+
"morris",
|
|
259
|
+
"parker",
|
|
260
|
+
"quinn",
|
|
261
|
+
"reed",
|
|
262
|
+
"sawyer",
|
|
263
|
+
"turner",
|
|
264
|
+
"walker",
|
|
265
|
+
"wright",
|
|
266
|
+
"young",
|
|
267
|
+
] as const;
|
|
268
|
+
|
|
269
|
+
const splitConfiguredDomains = (value?: string): string[] =>
|
|
270
|
+
String(value || "")
|
|
271
|
+
.split(",")
|
|
272
|
+
.map(normalizeEmailDomain)
|
|
273
|
+
.filter(Boolean);
|
|
274
|
+
|
|
275
|
+
const extractDomainFromEmailValue = (value?: string): string => {
|
|
276
|
+
const raw = String(value || "").trim().toLowerCase();
|
|
277
|
+
if (!raw) return "";
|
|
278
|
+
const match = raw.match(/<?([^<>\s]+@[^<>\s]+)>?/);
|
|
279
|
+
if (!match?.[1]) return "";
|
|
280
|
+
const [, domain = ""] = match[1].split("@");
|
|
281
|
+
return normalizeEmailDomain(domain);
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const configuredAgentEmailDomains = (): string[] => {
|
|
285
|
+
const domains = [
|
|
286
|
+
...splitConfiguredDomains(process.env.AGENT_EMAIL_DOMAINS),
|
|
287
|
+
...splitConfiguredDomains(process.env.AGENT_EMAIL_DOMAIN),
|
|
288
|
+
...splitConfiguredDomains(process.env.EMAIL_AGENT_DOMAINS),
|
|
289
|
+
...splitConfiguredDomains(process.env.EMAIL_AGENT_DOMAIN),
|
|
290
|
+
...splitConfiguredDomains(process.env.EMAIL_INBOUND_DOMAINS),
|
|
291
|
+
...splitConfiguredDomains(process.env.EMAIL_INBOUND_DOMAIN),
|
|
292
|
+
extractDomainFromEmailValue(process.env.AGENT_EMAIL_E2E_FROM),
|
|
293
|
+
extractDomainFromEmailValue(process.env.CLOUDFLARE_EMAIL_FROM),
|
|
294
|
+
extractDomainFromEmailValue(process.env.EMAIL_FROM),
|
|
295
|
+
extractDomainFromEmailValue(process.env.RESEND_FROM_EMAIL),
|
|
296
|
+
];
|
|
297
|
+
return Array.from(new Set(domains.filter(Boolean)));
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const resolveAgentEmailDomain = (requestedDomain?: unknown): string => {
|
|
301
|
+
const configured = configuredAgentEmailDomains();
|
|
302
|
+
const requested = normalizeEmailDomain(requestedDomain);
|
|
303
|
+
if (requested) {
|
|
304
|
+
if (configured.length === 0) {
|
|
305
|
+
throw apiError(
|
|
306
|
+
"invalid_input",
|
|
307
|
+
"AGENT_EMAIL_DOMAIN or AGENT_EMAIL_DOMAINS must be configured"
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
if (configured.length > 0 && !configured.includes(requested)) {
|
|
311
|
+
throw apiError("forbidden", "Requested email domain is not allowed");
|
|
312
|
+
}
|
|
313
|
+
return requested;
|
|
314
|
+
}
|
|
315
|
+
const first = configured[0];
|
|
316
|
+
if (!first) {
|
|
317
|
+
throw apiError(
|
|
318
|
+
"invalid_input",
|
|
319
|
+
"AGENT_EMAIL_DOMAIN or AGENT_EMAIL_DOMAINS must be configured"
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
return first;
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const normalizeProviderName = (value?: unknown): string =>
|
|
326
|
+
typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
327
|
+
|
|
328
|
+
const resolveCloudflareEmailRoutingConfig = () => {
|
|
329
|
+
const zoneId =
|
|
330
|
+
process.env.CLOUDFLARE_EMAIL_ROUTING_ZONE_ID?.trim() ||
|
|
331
|
+
process.env.CLOUDFLARE_ZONE_ID?.trim() ||
|
|
332
|
+
"";
|
|
333
|
+
const apiToken =
|
|
334
|
+
process.env.CLOUDFLARE_EMAIL_ROUTING_API_TOKEN?.trim() ||
|
|
335
|
+
process.env.CLOUDFLARE_API_TOKEN?.trim() ||
|
|
336
|
+
"";
|
|
337
|
+
const workerName =
|
|
338
|
+
process.env.CLOUDFLARE_EMAIL_ROUTING_WORKER_NAME?.trim() ||
|
|
339
|
+
DEFAULT_CLOUDFLARE_EMAIL_ROUTING_WORKER;
|
|
340
|
+
if (!zoneId || !apiToken || !workerName) return null;
|
|
341
|
+
return {
|
|
342
|
+
zoneId,
|
|
343
|
+
apiToken,
|
|
344
|
+
workerName,
|
|
345
|
+
apiBaseUrl:
|
|
346
|
+
process.env.CLOUDFLARE_EMAIL_ROUTING_API_BASE_URL?.trim() ||
|
|
347
|
+
"https://api.cloudflare.com/client/v4",
|
|
348
|
+
};
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
const parseCloudflareApiJson = async (response: Response) => {
|
|
352
|
+
const text = await response.text();
|
|
353
|
+
try {
|
|
354
|
+
return text ? JSON.parse(text) : null;
|
|
355
|
+
} catch {
|
|
356
|
+
return { rawText: text };
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
const cloudflareApiErrorMessage = (payload: any, fallback: string): string => {
|
|
361
|
+
const errors = Array.isArray(payload?.errors)
|
|
362
|
+
? payload.errors
|
|
363
|
+
.map((error: any) => error?.message || error?.code)
|
|
364
|
+
.filter(Boolean)
|
|
365
|
+
: [];
|
|
366
|
+
return errors.length > 0 ? errors.join("; ") : fallback;
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
const isMatchingCloudflareWorkerRoute = (
|
|
370
|
+
rule: any,
|
|
371
|
+
emailAddress: string,
|
|
372
|
+
workerName: string
|
|
373
|
+
) => {
|
|
374
|
+
const matchers = Array.isArray(rule?.matchers) ? rule.matchers : [];
|
|
375
|
+
const matcher = matchers.find(
|
|
376
|
+
(item: any) =>
|
|
377
|
+
item?.type === "literal" &&
|
|
378
|
+
item?.field === "to" &&
|
|
379
|
+
normalizeEmailAddress(item?.value) === emailAddress
|
|
380
|
+
);
|
|
381
|
+
if (!matcher) return false;
|
|
382
|
+
const actions = Array.isArray(rule?.actions) ? rule.actions : [];
|
|
383
|
+
return actions.some(
|
|
384
|
+
(action: any) =>
|
|
385
|
+
action?.type === "worker" &&
|
|
386
|
+
Array.isArray(action?.value) &&
|
|
387
|
+
action.value.includes(workerName)
|
|
388
|
+
);
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
const hasConflictingCloudflareRoute = (rule: any, emailAddress: string): boolean => {
|
|
392
|
+
const matchers = Array.isArray(rule?.matchers) ? rule.matchers : [];
|
|
393
|
+
return matchers.some(
|
|
394
|
+
(item: any) =>
|
|
395
|
+
item?.type === "literal" &&
|
|
396
|
+
item?.field === "to" &&
|
|
397
|
+
normalizeEmailAddress(item?.value) === emailAddress
|
|
398
|
+
);
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
const ensureCloudflareEmailRoutingRule = async (
|
|
402
|
+
emailAddress: string,
|
|
403
|
+
provider?: unknown
|
|
404
|
+
) => {
|
|
405
|
+
if (normalizeProviderName(provider) !== "cloudflare") return;
|
|
406
|
+
const config = resolveCloudflareEmailRoutingConfig();
|
|
407
|
+
if (!config) return;
|
|
408
|
+
|
|
409
|
+
const url = `${config.apiBaseUrl.replace(/\/+$/, "")}/zones/${encodeURIComponent(
|
|
410
|
+
config.zoneId
|
|
411
|
+
)}/email/routing/rules`;
|
|
412
|
+
const headers = {
|
|
413
|
+
Authorization: `Bearer ${config.apiToken}`,
|
|
414
|
+
"Content-Type": "application/json",
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
const listResponse = await fetch(url, {
|
|
418
|
+
method: "GET",
|
|
419
|
+
headers,
|
|
420
|
+
});
|
|
421
|
+
const listPayload = await parseCloudflareApiJson(listResponse);
|
|
422
|
+
if (!listResponse.ok || listPayload?.success === false) {
|
|
423
|
+
throw apiError(
|
|
424
|
+
"internal_error",
|
|
425
|
+
`Failed to list Cloudflare email routing rules for ${emailAddress}: ${cloudflareApiErrorMessage(
|
|
426
|
+
listPayload,
|
|
427
|
+
`HTTP ${listResponse.status}`
|
|
428
|
+
)}`
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const existingRules = Array.isArray(listPayload?.result) ? listPayload.result : [];
|
|
433
|
+
const matchingRule = existingRules.find((rule: any) =>
|
|
434
|
+
isMatchingCloudflareWorkerRoute(rule, emailAddress, config.workerName)
|
|
435
|
+
);
|
|
436
|
+
if (matchingRule && matchingRule.enabled !== false) {
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const conflictingRule = existingRules.find((rule: any) =>
|
|
441
|
+
hasConflictingCloudflareRoute(rule, emailAddress)
|
|
442
|
+
);
|
|
443
|
+
if (conflictingRule) {
|
|
444
|
+
throw apiError(
|
|
445
|
+
"invalid_input",
|
|
446
|
+
`Cloudflare email routing already has a conflicting rule for ${emailAddress}`
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const createResponse = await fetch(url, {
|
|
451
|
+
method: "POST",
|
|
452
|
+
headers,
|
|
453
|
+
body: JSON.stringify({
|
|
454
|
+
name: `Agent email route ${emailAddress}`,
|
|
455
|
+
enabled: true,
|
|
456
|
+
priority: 0,
|
|
457
|
+
matchers: [
|
|
458
|
+
{
|
|
459
|
+
type: "literal",
|
|
460
|
+
field: "to",
|
|
461
|
+
value: emailAddress,
|
|
462
|
+
},
|
|
463
|
+
],
|
|
464
|
+
actions: [
|
|
465
|
+
{
|
|
466
|
+
type: "worker",
|
|
467
|
+
value: [config.workerName],
|
|
468
|
+
},
|
|
469
|
+
],
|
|
470
|
+
}),
|
|
471
|
+
});
|
|
472
|
+
const createPayload = await parseCloudflareApiJson(createResponse);
|
|
473
|
+
if (!createResponse.ok || createPayload?.success === false) {
|
|
474
|
+
throw apiError(
|
|
475
|
+
"internal_error",
|
|
476
|
+
`Failed to create Cloudflare email routing rule for ${emailAddress}: ${cloudflareApiErrorMessage(
|
|
477
|
+
createPayload,
|
|
478
|
+
`HTTP ${createResponse.status}`
|
|
479
|
+
)}`
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
const resolveRouteReadinessSender = (domain: string): EmailParticipant => {
|
|
485
|
+
const configured = [
|
|
486
|
+
process.env.AGENT_EMAIL_E2E_FROM,
|
|
487
|
+
process.env.CLOUDFLARE_EMAIL_FROM,
|
|
488
|
+
process.env.EMAIL_FROM,
|
|
489
|
+
process.env.RESEND_FROM_EMAIL,
|
|
490
|
+
]
|
|
491
|
+
.map((item) => String(item || "").trim())
|
|
492
|
+
.find(Boolean);
|
|
493
|
+
const normalized = normalizeEmailAddress(configured);
|
|
494
|
+
if (normalized) {
|
|
495
|
+
return { email: normalized };
|
|
496
|
+
}
|
|
497
|
+
return { email: `no-reply@${domain}` };
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
const parsePositiveInt = (value: unknown, fallback: number): number => {
|
|
501
|
+
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
502
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
|
|
503
|
+
return parsed;
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
507
|
+
|
|
508
|
+
const findRecentInboundEmail = async (
|
|
509
|
+
agentId: string,
|
|
510
|
+
matcher: (record: EmailRecord) => boolean
|
|
511
|
+
): Promise<EmailRecord | null> => {
|
|
512
|
+
const range = emailKey.rangeOfOwner(agentId);
|
|
513
|
+
for await (const [, value] of serverDb.iterator({
|
|
514
|
+
gte: range.start,
|
|
515
|
+
lte: range.end,
|
|
516
|
+
reverse: true,
|
|
517
|
+
})) {
|
|
518
|
+
const record = value as EmailRecord | null;
|
|
519
|
+
if (!record || record.type !== DataType.EMAIL || (record as any).deletedAt) continue;
|
|
520
|
+
if (matcher(record)) return record;
|
|
521
|
+
}
|
|
522
|
+
return null;
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
const archiveRouteReadinessProbe = async (record: EmailRecord) => {
|
|
526
|
+
await serverDb.put(record.dbKey, {
|
|
527
|
+
...record,
|
|
528
|
+
mailbox: "archive",
|
|
529
|
+
tags: normalizeTags([...(record.tags || []), "route-readiness-probe"]),
|
|
530
|
+
updatedAt: new Date().toISOString(),
|
|
531
|
+
meta: {
|
|
532
|
+
...(record.meta || {}),
|
|
533
|
+
routeReadinessProbe: true,
|
|
534
|
+
},
|
|
535
|
+
});
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
const waitForCloudflareRouteReadiness = async (
|
|
539
|
+
agentId: string,
|
|
540
|
+
emailAddress: string,
|
|
541
|
+
domain: string,
|
|
542
|
+
provider?: unknown
|
|
543
|
+
): Promise<string> => {
|
|
544
|
+
if (normalizeProviderName(provider) !== "cloudflare") {
|
|
545
|
+
return new Date().toISOString();
|
|
546
|
+
}
|
|
547
|
+
if (!resolveCloudflareEmailRoutingConfig()) {
|
|
548
|
+
return new Date().toISOString();
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const readinessEnabled = String(process.env.CLOUDFLARE_EMAIL_ROUTE_READY_ENABLED || "1")
|
|
552
|
+
.trim()
|
|
553
|
+
.toLowerCase();
|
|
554
|
+
if (readinessEnabled === "0" || readinessEnabled === "false") {
|
|
555
|
+
return new Date().toISOString();
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const timeoutMs = parsePositiveInt(
|
|
559
|
+
process.env.CLOUDFLARE_EMAIL_ROUTE_READY_TIMEOUT_MS,
|
|
560
|
+
DEFAULT_CLOUDFLARE_EMAIL_ROUTE_READY_TIMEOUT_MS
|
|
561
|
+
);
|
|
562
|
+
const pollMs = parsePositiveInt(
|
|
563
|
+
process.env.CLOUDFLARE_EMAIL_ROUTE_READY_POLL_MS,
|
|
564
|
+
DEFAULT_CLOUDFLARE_EMAIL_ROUTE_READY_POLL_MS
|
|
565
|
+
);
|
|
566
|
+
const providerClient = getEmailProvider();
|
|
567
|
+
const now = Date.now();
|
|
568
|
+
const probeToken = `${now.toString(36)}-${hashToken(`${agentId}:${emailAddress}:${now}`)}`;
|
|
569
|
+
const subject = `Agent email route readiness ${probeToken}`;
|
|
570
|
+
const text = `route-readiness-probe:${probeToken}`;
|
|
571
|
+
const startedAt = Date.now();
|
|
572
|
+
|
|
573
|
+
await providerClient.sendEmail({
|
|
574
|
+
from: resolveRouteReadinessSender(domain),
|
|
575
|
+
to: [{ email: emailAddress }],
|
|
576
|
+
subject,
|
|
577
|
+
text,
|
|
578
|
+
headers: {
|
|
579
|
+
"X-Nolo-Route-Readiness": probeToken,
|
|
580
|
+
},
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
while (Date.now() - startedAt <= timeoutMs) {
|
|
584
|
+
const matched = await findRecentInboundEmail(agentId, (record) => {
|
|
585
|
+
if (record.mailbox !== "inbox") return false;
|
|
586
|
+
if (record.subject !== subject) return false;
|
|
587
|
+
if (!record.to?.some((item) => normalizeEmailAddress(item?.email) === emailAddress)) {
|
|
588
|
+
return false;
|
|
589
|
+
}
|
|
590
|
+
const createdAt = Number(new Date(record.createdAt).getTime());
|
|
591
|
+
return Number.isFinite(createdAt) ? createdAt >= startedAt - 1000 : true;
|
|
592
|
+
});
|
|
593
|
+
if (matched) {
|
|
594
|
+
await archiveRouteReadinessProbe(matched);
|
|
595
|
+
return new Date().toISOString();
|
|
596
|
+
}
|
|
597
|
+
await sleep(Math.min(pollMs, timeoutMs));
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
throw apiError(
|
|
601
|
+
"internal_error",
|
|
602
|
+
`Cloudflare email route for ${emailAddress} was created but did not become ingress-ready in time`
|
|
603
|
+
);
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
const assertValidLocalPart = (localPart: string) => {
|
|
607
|
+
if (
|
|
608
|
+
localPart.length < 1 ||
|
|
609
|
+
localPart.length > 64 ||
|
|
610
|
+
!/^[a-z0-9][a-z0-9._+-]*[a-z0-9]$|^[a-z0-9]$/.test(localPart)
|
|
611
|
+
) {
|
|
612
|
+
throw apiError(
|
|
613
|
+
"invalid_input",
|
|
614
|
+
"localPart must be 1-64 chars and contain only letters, numbers, dot, underscore, plus, or hyphen"
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
};
|
|
618
|
+
|
|
619
|
+
const buildProvisionedLocalPart = (agentId: string, purpose?: unknown): string => {
|
|
620
|
+
const purposeSlug = normalizePurpose(purpose);
|
|
621
|
+
const token = hashToken(`${agentId}:${purposeSlug || "primary"}`);
|
|
622
|
+
const firstHash = Number.parseInt(hashToken(`${token}:first`), 36);
|
|
623
|
+
const lastHash = Number.parseInt(hashToken(`${token}:last`), 36);
|
|
624
|
+
const firstName =
|
|
625
|
+
PROVISIONED_EMAIL_FIRST_NAMES[firstHash % PROVISIONED_EMAIL_FIRST_NAMES.length];
|
|
626
|
+
const lastName =
|
|
627
|
+
PROVISIONED_EMAIL_LAST_NAMES[lastHash % PROVISIONED_EMAIL_LAST_NAMES.length];
|
|
628
|
+
const suffix = token.slice(-4);
|
|
629
|
+
return `${firstName}.${lastName}-${suffix}`;
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
const getAgentEmailIdentities = (agent: any): AgentEmailIdentity[] => {
|
|
633
|
+
const identities: AgentEmailIdentity[] = [];
|
|
634
|
+
const seen = new Set<string>();
|
|
635
|
+
const push = (identity: Partial<AgentEmailIdentity> | null | undefined) => {
|
|
636
|
+
const emailAddress = normalizeEmailAddress(identity?.emailAddress);
|
|
637
|
+
if (!emailAddress || seen.has(emailAddress) || identity?.disabledAt) return;
|
|
638
|
+
seen.add(emailAddress);
|
|
639
|
+
identities.push({
|
|
640
|
+
...identity,
|
|
641
|
+
emailAddress,
|
|
642
|
+
});
|
|
643
|
+
};
|
|
644
|
+
|
|
645
|
+
push({
|
|
646
|
+
emailAddress: agent?.meta?.emailAddress,
|
|
647
|
+
provider: agent?.meta?.emailProvider,
|
|
648
|
+
verifiedAt: agent?.meta?.emailVerifiedAt,
|
|
649
|
+
readinessStatus: agent?.meta?.emailReadinessStatus,
|
|
650
|
+
ingressReadyAt: agent?.meta?.emailIngressReadyAt,
|
|
651
|
+
lastWarmupAt: agent?.meta?.emailLastWarmupAt,
|
|
652
|
+
lastWarmupError: agent?.meta?.emailLastWarmupError,
|
|
653
|
+
source: "bound",
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
if (Array.isArray(agent?.meta?.emailIdentities)) {
|
|
657
|
+
agent.meta.emailIdentities.forEach((identity: any) => push(identity));
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
return identities;
|
|
661
|
+
};
|
|
662
|
+
|
|
663
|
+
const hasAgentEmailIdentity = (agent: any, emailAddress: string): boolean => {
|
|
664
|
+
const normalized = normalizeEmailAddress(emailAddress);
|
|
665
|
+
return getAgentEmailIdentities(agent).some(
|
|
666
|
+
(identity) => identity.emailAddress === normalized
|
|
667
|
+
);
|
|
668
|
+
};
|
|
669
|
+
|
|
670
|
+
const getAgentEmailIdentity = (
|
|
671
|
+
agent: any,
|
|
672
|
+
emailAddress: string
|
|
673
|
+
): AgentEmailIdentity | undefined => {
|
|
674
|
+
const normalized = normalizeEmailAddress(emailAddress);
|
|
675
|
+
return getAgentEmailIdentities(agent).find(
|
|
676
|
+
(identity) => identity.emailAddress === normalized
|
|
677
|
+
);
|
|
678
|
+
};
|
|
679
|
+
|
|
680
|
+
const withReadinessState = (
|
|
681
|
+
identity: AgentEmailIdentity,
|
|
682
|
+
patch: Partial<
|
|
683
|
+
Pick<
|
|
684
|
+
AgentEmailIdentity,
|
|
685
|
+
"readinessStatus" | "ingressReadyAt" | "lastWarmupAt" | "lastWarmupError"
|
|
686
|
+
>
|
|
687
|
+
>
|
|
688
|
+
): AgentEmailIdentity => ({
|
|
689
|
+
...identity,
|
|
690
|
+
...patch,
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
const assertValidEmailAddress = (emailAddress: string) => {
|
|
694
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(emailAddress)) {
|
|
695
|
+
throw apiError("invalid_input", "emailAddress must be a valid email address");
|
|
696
|
+
}
|
|
697
|
+
};
|
|
698
|
+
|
|
699
|
+
const upsertAgentEmailIdentity = (
|
|
700
|
+
agent: any,
|
|
701
|
+
identity: AgentEmailIdentity,
|
|
702
|
+
options: { makePrimary?: boolean } = {}
|
|
703
|
+
) => {
|
|
704
|
+
const emailAddress = normalizeEmailAddress(identity.emailAddress);
|
|
705
|
+
assertValidEmailAddress(emailAddress);
|
|
706
|
+
const now = new Date().toISOString();
|
|
707
|
+
const existingIdentities = Array.isArray(agent?.meta?.emailIdentities)
|
|
708
|
+
? agent.meta.emailIdentities
|
|
709
|
+
: [];
|
|
710
|
+
const nextIdentity: AgentEmailIdentity = {
|
|
711
|
+
...identity,
|
|
712
|
+
emailAddress,
|
|
713
|
+
createdAt: identity.createdAt || now,
|
|
714
|
+
verifiedAt: identity.verifiedAt || now,
|
|
715
|
+
};
|
|
716
|
+
const nextIdentities = [
|
|
717
|
+
nextIdentity,
|
|
718
|
+
...existingIdentities.filter(
|
|
719
|
+
(item: any) => normalizeEmailAddress(item?.emailAddress) !== emailAddress
|
|
720
|
+
),
|
|
721
|
+
];
|
|
722
|
+
const currentPrimaryEmail = normalizeEmailAddress(agent?.meta?.emailAddress);
|
|
723
|
+
const shouldMakePrimary =
|
|
724
|
+
options.makePrimary ?? (!currentPrimaryEmail || currentPrimaryEmail === emailAddress);
|
|
725
|
+
|
|
726
|
+
return {
|
|
727
|
+
...agent,
|
|
728
|
+
meta: {
|
|
729
|
+
...(agent.meta || {}),
|
|
730
|
+
emailAddress: shouldMakePrimary
|
|
731
|
+
? emailAddress
|
|
732
|
+
: normalizeEmailAddress(agent?.meta?.emailAddress) || emailAddress,
|
|
733
|
+
emailProvider:
|
|
734
|
+
shouldMakePrimary || !agent?.meta?.emailProvider
|
|
735
|
+
? nextIdentity.provider || agent?.meta?.emailProvider || DEFAULT_AGENT_EMAIL_PROVIDER
|
|
736
|
+
: agent.meta.emailProvider,
|
|
737
|
+
emailVerifiedAt:
|
|
738
|
+
shouldMakePrimary || !agent?.meta?.emailVerifiedAt
|
|
739
|
+
? nextIdentity.verifiedAt
|
|
740
|
+
: agent.meta.emailVerifiedAt,
|
|
741
|
+
emailIngressReadyAt:
|
|
742
|
+
shouldMakePrimary || !agent?.meta?.emailIngressReadyAt
|
|
743
|
+
? nextIdentity.ingressReadyAt
|
|
744
|
+
: agent.meta.emailIngressReadyAt,
|
|
745
|
+
emailReadinessStatus:
|
|
746
|
+
shouldMakePrimary || !agent?.meta?.emailReadinessStatus
|
|
747
|
+
? nextIdentity.readinessStatus
|
|
748
|
+
: agent.meta.emailReadinessStatus,
|
|
749
|
+
emailLastWarmupAt:
|
|
750
|
+
shouldMakePrimary || !agent?.meta?.emailLastWarmupAt
|
|
751
|
+
? nextIdentity.lastWarmupAt
|
|
752
|
+
: agent.meta.emailLastWarmupAt,
|
|
753
|
+
emailLastWarmupError:
|
|
754
|
+
shouldMakePrimary || !agent?.meta?.emailLastWarmupError
|
|
755
|
+
? nextIdentity.lastWarmupError
|
|
756
|
+
: agent.meta.emailLastWarmupError,
|
|
757
|
+
emailIdentities: nextIdentities,
|
|
758
|
+
},
|
|
759
|
+
updatedAt: now,
|
|
760
|
+
};
|
|
761
|
+
};
|
|
762
|
+
|
|
763
|
+
const buildBindIdentityResult = (
|
|
764
|
+
agentId: string,
|
|
765
|
+
emailAddress: string,
|
|
766
|
+
agent: any
|
|
767
|
+
) => {
|
|
768
|
+
const identity = getAgentEmailIdentity(agent, emailAddress);
|
|
769
|
+
return {
|
|
770
|
+
agentId,
|
|
771
|
+
emailAddress,
|
|
772
|
+
readinessStatus: identity?.readinessStatus || null,
|
|
773
|
+
ingressReadyAt: identity?.ingressReadyAt || null,
|
|
774
|
+
lastWarmupAt: identity?.lastWarmupAt || null,
|
|
775
|
+
lastWarmupError: identity?.lastWarmupError || null,
|
|
776
|
+
agent,
|
|
777
|
+
delegationResourcePrefix: `email-${agentId}-`,
|
|
778
|
+
};
|
|
779
|
+
};
|
|
780
|
+
|
|
781
|
+
const buildProvisionIdentityResult = (
|
|
782
|
+
agentId: string,
|
|
783
|
+
emailAddress: string,
|
|
784
|
+
localPart: string,
|
|
785
|
+
domain: string,
|
|
786
|
+
provider: string,
|
|
787
|
+
purpose: string | null,
|
|
788
|
+
agent: any
|
|
789
|
+
) => {
|
|
790
|
+
const identity = getAgentEmailIdentity(agent, emailAddress);
|
|
791
|
+
return {
|
|
792
|
+
agentId,
|
|
793
|
+
emailAddress,
|
|
794
|
+
localPart,
|
|
795
|
+
domain,
|
|
796
|
+
provider,
|
|
797
|
+
purpose,
|
|
798
|
+
readinessStatus: identity?.readinessStatus || null,
|
|
799
|
+
ingressReadyAt: identity?.ingressReadyAt || null,
|
|
800
|
+
lastWarmupAt: identity?.lastWarmupAt || null,
|
|
801
|
+
lastWarmupError: identity?.lastWarmupError || null,
|
|
802
|
+
agent,
|
|
803
|
+
delegationResourcePrefix: `email-${agentId}-`,
|
|
804
|
+
};
|
|
805
|
+
};
|
|
806
|
+
|
|
807
|
+
const loadOwnedAgent = async (
|
|
808
|
+
agentId: string,
|
|
809
|
+
principalUserId: string
|
|
810
|
+
): Promise<any> => {
|
|
811
|
+
if (!agentId?.trim()) throw apiError("invalid_input", "agentId is required");
|
|
812
|
+
const agent = await serverDb.get(agentId).catch(() => null);
|
|
813
|
+
if (!agent || agent.deletedAt) throw apiError("not_found", "Agent not found");
|
|
814
|
+
|
|
815
|
+
const access = await authorizeActorRecordAccess({
|
|
816
|
+
action: "read",
|
|
817
|
+
actor: { type: "user", userId: principalUserId },
|
|
818
|
+
dbKey: agentId,
|
|
819
|
+
record: agent,
|
|
820
|
+
});
|
|
821
|
+
if (!access.allowed) {
|
|
822
|
+
throw apiError("forbidden", "You do not own this agent");
|
|
823
|
+
}
|
|
824
|
+
return agent;
|
|
825
|
+
};
|
|
826
|
+
|
|
827
|
+
export const findAgentByEmailAddress = async (email: string): Promise<any | null> => {
|
|
828
|
+
const normalized = normalizeEmailAddress(email);
|
|
829
|
+
if (!normalized) return null;
|
|
830
|
+
|
|
831
|
+
for await (const [, value] of serverDb.iterator({
|
|
832
|
+
gte: "agent-",
|
|
833
|
+
lte: "agent-\uffff",
|
|
834
|
+
})) {
|
|
835
|
+
if (hasAgentEmailIdentity(value, normalized) && !value?.deletedAt) {
|
|
836
|
+
return value;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
return null;
|
|
840
|
+
};
|
|
841
|
+
|
|
842
|
+
const findExistingInboundEmail = async (
|
|
843
|
+
ownerId: string,
|
|
844
|
+
messageId?: string,
|
|
845
|
+
providerMessageId?: string
|
|
846
|
+
): Promise<EmailRecord | null> => {
|
|
847
|
+
if (!messageId && !providerMessageId) return null;
|
|
848
|
+
const { start, end } = emailKey.rangeOfOwner(ownerId);
|
|
849
|
+
for await (const [, value] of serverDb.iterator({ gte: start, lte: end })) {
|
|
850
|
+
if (!value || value.type !== DataType.EMAIL || value.deletedAt) continue;
|
|
851
|
+
const record = value as EmailRecord;
|
|
852
|
+
if (messageId && record.messageId === messageId) return record;
|
|
853
|
+
if (
|
|
854
|
+
providerMessageId &&
|
|
855
|
+
(record.meta as any)?.provider?.messageId === providerMessageId
|
|
856
|
+
) {
|
|
857
|
+
return record;
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
return null;
|
|
861
|
+
};
|
|
862
|
+
|
|
863
|
+
export async function mirrorEmailToControlledInboxes(
|
|
864
|
+
params: MirrorEmailToControlledInboxesParams
|
|
865
|
+
): Promise<EmailRecord[]> {
|
|
866
|
+
const recipients = normalizeParticipants([
|
|
867
|
+
...(params.to || []),
|
|
868
|
+
...(params.cc || []),
|
|
869
|
+
...(params.bcc || []),
|
|
870
|
+
]);
|
|
871
|
+
const mirrored: EmailRecord[] = [];
|
|
872
|
+
const mirroredOwners = new Set<string>();
|
|
873
|
+
|
|
874
|
+
for (const recipient of recipients) {
|
|
875
|
+
const agent = await findAgentByEmailAddress(recipient.email);
|
|
876
|
+
if (!agent?.dbKey || mirroredOwners.has(agent.dbKey)) continue;
|
|
877
|
+
mirroredOwners.add(agent.dbKey);
|
|
878
|
+
|
|
879
|
+
const existingRecord = await findExistingInboundEmail(
|
|
880
|
+
agent.dbKey,
|
|
881
|
+
undefined,
|
|
882
|
+
params.providerMessageId
|
|
883
|
+
);
|
|
884
|
+
if (existingRecord) {
|
|
885
|
+
mirrored.push(existingRecord);
|
|
886
|
+
continue;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
const now = params.providerReceivedAt || new Date().toISOString();
|
|
890
|
+
const record = createEmailRecord({
|
|
891
|
+
ownerType: "agent",
|
|
892
|
+
ownerId: agent.dbKey,
|
|
893
|
+
tenantId: resolveKeyOwnerId(agent.dbKey) || agent.ownerId || agent.dbKey,
|
|
894
|
+
mailbox: "inbox",
|
|
895
|
+
status: "received",
|
|
896
|
+
from: params.from,
|
|
897
|
+
to: [recipient],
|
|
898
|
+
cc: params.cc,
|
|
899
|
+
bcc: params.bcc,
|
|
900
|
+
replyTo: params.replyTo,
|
|
901
|
+
subject: params.subject,
|
|
902
|
+
text: params.text,
|
|
903
|
+
html: params.html,
|
|
904
|
+
messageId: params.providerMessageId,
|
|
905
|
+
meta: {
|
|
906
|
+
...(params.meta || {}),
|
|
907
|
+
provider: {
|
|
908
|
+
name: params.providerName,
|
|
909
|
+
messageId: params.providerMessageId,
|
|
910
|
+
mirrored: true,
|
|
911
|
+
receivedAt: now,
|
|
912
|
+
rawHeaders: params.rawHeaders,
|
|
913
|
+
raw: params.raw,
|
|
914
|
+
},
|
|
915
|
+
},
|
|
916
|
+
now,
|
|
917
|
+
});
|
|
918
|
+
await serverDb.put(record.dbKey, record);
|
|
919
|
+
mirrored.push(record);
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
return mirrored;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
const allowedRecipientEmails = async (recipients: EmailParticipant[]) => {
|
|
926
|
+
const allowlist = new Set(
|
|
927
|
+
String(process.env.EMAIL_TEST_ALLOWLIST || "")
|
|
928
|
+
.split(",")
|
|
929
|
+
.map((item) => normalizeEmailAddress(item))
|
|
930
|
+
.filter(Boolean)
|
|
931
|
+
);
|
|
932
|
+
|
|
933
|
+
const denied: string[] = [];
|
|
934
|
+
for (const recipient of recipients) {
|
|
935
|
+
const email = normalizeEmailAddress(recipient.email);
|
|
936
|
+
if (allowlist.has(email)) continue;
|
|
937
|
+
if (await findAgentByEmailAddress(email)) continue;
|
|
938
|
+
denied.push(recipient.email);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
return denied;
|
|
942
|
+
};
|
|
943
|
+
|
|
944
|
+
const providerMeta = (
|
|
945
|
+
provider: EmailProvider,
|
|
946
|
+
result: EmailProviderSendResult,
|
|
947
|
+
extra?: Record<string, unknown>
|
|
948
|
+
) => ({
|
|
949
|
+
provider: {
|
|
950
|
+
name: provider.name,
|
|
951
|
+
messageId: result.providerMessageId,
|
|
952
|
+
delivered: result.delivered,
|
|
953
|
+
queued: result.queued,
|
|
954
|
+
permanentBounces: result.permanentBounces,
|
|
955
|
+
error: result.error,
|
|
956
|
+
raw: result.raw,
|
|
957
|
+
...extra,
|
|
958
|
+
},
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
const ensureAgentEmailDelegation = async (
|
|
962
|
+
principalUserId: string,
|
|
963
|
+
agentId: string
|
|
964
|
+
) => {
|
|
965
|
+
const key = agentDelegationKey(principalUserId, agentId);
|
|
966
|
+
const existing = await serverDb.get(key).catch(() => null);
|
|
967
|
+
const scopes = new Set(Array.isArray(existing?.scopes) ? existing.scopes : []);
|
|
968
|
+
["email:read", "email:manage", "email:send"].forEach((scope) => scopes.add(scope));
|
|
969
|
+
|
|
970
|
+
const prefixes = new Set(
|
|
971
|
+
Array.isArray(existing?.resourcePrefixes) ? existing.resourcePrefixes : []
|
|
972
|
+
);
|
|
973
|
+
prefixes.add(`email-${agentId}-`);
|
|
974
|
+
|
|
975
|
+
await serverDb.put(key, {
|
|
976
|
+
...existing,
|
|
977
|
+
principalUserId,
|
|
978
|
+
agentId,
|
|
979
|
+
scopes: Array.from(scopes),
|
|
980
|
+
resourcePrefixes: Array.from(prefixes),
|
|
981
|
+
});
|
|
982
|
+
};
|
|
983
|
+
|
|
984
|
+
export function createEmailRecord(input: CreateEmailRecordInput): EmailRecord {
|
|
985
|
+
if (!input.ownerId?.trim()) {
|
|
986
|
+
throw apiError("invalid_input", "ownerId is required");
|
|
987
|
+
}
|
|
988
|
+
if (!input.from?.email?.trim()) {
|
|
989
|
+
throw apiError("invalid_input", "from.email is required");
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
const ownerId = input.ownerId.trim();
|
|
993
|
+
const { dbKey, emailId } = emailKey.create(ownerId);
|
|
994
|
+
const now = input.now ?? new Date().toISOString();
|
|
995
|
+
|
|
996
|
+
return {
|
|
997
|
+
dbKey,
|
|
998
|
+
type: DataType.EMAIL,
|
|
999
|
+
ownerType: input.ownerType ?? "user",
|
|
1000
|
+
ownerId,
|
|
1001
|
+
tenantId: input.tenantId ?? ownerId,
|
|
1002
|
+
spaceId: input.spaceId ?? null,
|
|
1003
|
+
mailbox: input.mailbox ?? "inbox",
|
|
1004
|
+
status: input.status ?? "received",
|
|
1005
|
+
from: input.from,
|
|
1006
|
+
to: Array.isArray(input.to) ? input.to : [],
|
|
1007
|
+
cc: input.cc,
|
|
1008
|
+
bcc: input.bcc,
|
|
1009
|
+
replyTo: input.replyTo,
|
|
1010
|
+
subject: input.subject ?? "",
|
|
1011
|
+
text: input.text,
|
|
1012
|
+
html: input.html,
|
|
1013
|
+
messageId: input.messageId ?? emailId,
|
|
1014
|
+
threadId: input.threadId,
|
|
1015
|
+
inReplyTo: input.inReplyTo,
|
|
1016
|
+
references: input.references,
|
|
1017
|
+
tags: normalizeTags(input.tags),
|
|
1018
|
+
meta: input.meta ?? {},
|
|
1019
|
+
createdAt: now,
|
|
1020
|
+
updatedAt: now,
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
export async function listEmails(
|
|
1025
|
+
params: ListEmailsParams = {},
|
|
1026
|
+
context?: ApiContext
|
|
1027
|
+
): Promise<EmailRecord[]> {
|
|
1028
|
+
const actor = requireActor(context);
|
|
1029
|
+
const ownerId = params.ownerId?.trim() || actorOwnerId(actor);
|
|
1030
|
+
if (!ownerId) throw apiError("forbidden", "Email owner is required");
|
|
1031
|
+
|
|
1032
|
+
const actorOwner = actorOwnerId(actor);
|
|
1033
|
+
const isOwnedAgentMailbox =
|
|
1034
|
+
actorOwner && resolveKeyOwnerId(ownerId) === actorOwner;
|
|
1035
|
+
if (ownerId !== actorOwner && !isOwnedAgentMailbox) {
|
|
1036
|
+
throw apiError("forbidden", "Cannot list emails for another owner");
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
const { start, end } = emailKey.rangeOfOwner(ownerId);
|
|
1040
|
+
const limit = Math.min(
|
|
1041
|
+
Math.max(Number(params.limit ?? 50), 1),
|
|
1042
|
+
MAX_LIST_LIMIT
|
|
1043
|
+
);
|
|
1044
|
+
const rows: EmailRecord[] = [];
|
|
1045
|
+
|
|
1046
|
+
for await (const [, value] of serverDb.iterator({ gte: start, lte: end })) {
|
|
1047
|
+
if (!value || value.type !== DataType.EMAIL || value.deletedAt) continue;
|
|
1048
|
+
const record = value as EmailRecord;
|
|
1049
|
+
if (params.mailbox && record.mailbox !== params.mailbox) continue;
|
|
1050
|
+
if (params.status && record.status !== params.status) continue;
|
|
1051
|
+
if (params.tag && !record.tags?.includes(params.tag)) continue;
|
|
1052
|
+
|
|
1053
|
+
const access = await authorizeActorRecordAccess({
|
|
1054
|
+
action: "read",
|
|
1055
|
+
actor,
|
|
1056
|
+
dbKey: record.dbKey,
|
|
1057
|
+
record,
|
|
1058
|
+
});
|
|
1059
|
+
if (!access.allowed) continue;
|
|
1060
|
+
|
|
1061
|
+
rows.push(record);
|
|
1062
|
+
if (rows.length >= limit) break;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
return rows.sort((a, b) => {
|
|
1066
|
+
const bTime = new Date(b.updatedAt).getTime();
|
|
1067
|
+
const aTime = new Date(a.updatedAt).getTime();
|
|
1068
|
+
return (Number.isFinite(bTime) ? bTime : 0) - (Number.isFinite(aTime) ? aTime : 0);
|
|
1069
|
+
});
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
export async function getEmail(
|
|
1073
|
+
params: EmailKeyParams,
|
|
1074
|
+
context?: ApiContext
|
|
1075
|
+
): Promise<EmailRecord> {
|
|
1076
|
+
const actor = requireActor(context);
|
|
1077
|
+
const record = await loadEmailRecord(params?.dbKey);
|
|
1078
|
+
|
|
1079
|
+
await assertEmailAccess(actor, "read", record);
|
|
1080
|
+
return record;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
export async function updateEmailTags(
|
|
1084
|
+
params: UpdateEmailTagsParams,
|
|
1085
|
+
context?: ApiContext
|
|
1086
|
+
): Promise<EmailRecord> {
|
|
1087
|
+
const actor = requireActor(context);
|
|
1088
|
+
const record = await loadEmailRecord(params?.dbKey);
|
|
1089
|
+
await assertEmailAccess(actor, "manage", record);
|
|
1090
|
+
|
|
1091
|
+
const updated: EmailRecord = {
|
|
1092
|
+
...record,
|
|
1093
|
+
tags: normalizeTags(params.tags),
|
|
1094
|
+
updatedAt: new Date().toISOString(),
|
|
1095
|
+
};
|
|
1096
|
+
await serverDb.put(record.dbKey, updated);
|
|
1097
|
+
return updated;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
export async function archiveEmail(
|
|
1101
|
+
params: EmailKeyParams,
|
|
1102
|
+
context?: ApiContext
|
|
1103
|
+
): Promise<EmailRecord> {
|
|
1104
|
+
const actor = requireActor(context);
|
|
1105
|
+
const record = await loadEmailRecord(params?.dbKey);
|
|
1106
|
+
await assertEmailAccess(actor, "manage", record);
|
|
1107
|
+
|
|
1108
|
+
const updated: EmailRecord = {
|
|
1109
|
+
...record,
|
|
1110
|
+
mailbox: "archive",
|
|
1111
|
+
updatedAt: new Date().toISOString(),
|
|
1112
|
+
};
|
|
1113
|
+
await serverDb.put(record.dbKey, updated);
|
|
1114
|
+
return updated;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
export async function sendEmail(
|
|
1118
|
+
params: SendEmailParams,
|
|
1119
|
+
context?: ApiContext,
|
|
1120
|
+
provider: EmailProvider = getEmailProvider()
|
|
1121
|
+
): Promise<EmailRecord> {
|
|
1122
|
+
if (!context?.userId) {
|
|
1123
|
+
throw apiError("unauthorized", "Authentication required");
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
const agentId = params?.agentId?.trim() || "";
|
|
1127
|
+
const agent = await loadOwnedAgent(agentId, context.userId);
|
|
1128
|
+
const fromEmail = normalizeEmailAddress(params?.from?.email);
|
|
1129
|
+
if (getAgentEmailIdentities(agent).length === 0) {
|
|
1130
|
+
throw apiError("invalid_input", "Agent does not have an email identity");
|
|
1131
|
+
}
|
|
1132
|
+
if (!fromEmail || !hasAgentEmailIdentity(agent, fromEmail)) {
|
|
1133
|
+
throw apiError("forbidden", "from must match one of the agent email identities");
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
const to = normalizeParticipants(params?.to);
|
|
1137
|
+
if (to.length === 0) {
|
|
1138
|
+
throw apiError("invalid_input", "to is required");
|
|
1139
|
+
}
|
|
1140
|
+
if (!params.subject?.trim()) {
|
|
1141
|
+
throw apiError("invalid_input", "subject is required");
|
|
1142
|
+
}
|
|
1143
|
+
if (!params.text?.trim() && !params.html?.trim()) {
|
|
1144
|
+
throw apiError("invalid_input", "text or html is required");
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
const deniedRecipients = await allowedRecipientEmails(to);
|
|
1148
|
+
if (deniedRecipients.length > 0) {
|
|
1149
|
+
throw apiError("forbidden", "Recipient is not an allowed agent or test address");
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
const now = new Date().toISOString();
|
|
1153
|
+
const queued = createEmailRecord({
|
|
1154
|
+
ownerType: "agent",
|
|
1155
|
+
ownerId: agentId,
|
|
1156
|
+
tenantId: context.userId,
|
|
1157
|
+
mailbox: "sent",
|
|
1158
|
+
status: "queued",
|
|
1159
|
+
from: { ...params.from, email: fromEmail },
|
|
1160
|
+
to,
|
|
1161
|
+
cc: normalizeParticipants(params.cc),
|
|
1162
|
+
bcc: normalizeParticipants(params.bcc),
|
|
1163
|
+
replyTo: normalizeParticipants(params.replyTo),
|
|
1164
|
+
subject: params.subject,
|
|
1165
|
+
text: params.text,
|
|
1166
|
+
html: params.html,
|
|
1167
|
+
meta: {
|
|
1168
|
+
agentId,
|
|
1169
|
+
requestedByUserId: context.userId,
|
|
1170
|
+
queuedAt: now,
|
|
1171
|
+
},
|
|
1172
|
+
now,
|
|
1173
|
+
});
|
|
1174
|
+
|
|
1175
|
+
await assertEmailAccess(
|
|
1176
|
+
{ type: "agent", agentId, principalUserId: context.userId },
|
|
1177
|
+
"write",
|
|
1178
|
+
queued
|
|
1179
|
+
);
|
|
1180
|
+
await serverDb.put(queued.dbKey, queued);
|
|
1181
|
+
|
|
1182
|
+
try {
|
|
1183
|
+
const result = await provider.sendEmail({
|
|
1184
|
+
from: queued.from,
|
|
1185
|
+
to: queued.to,
|
|
1186
|
+
cc: queued.cc,
|
|
1187
|
+
bcc: queued.bcc,
|
|
1188
|
+
replyTo: queued.replyTo,
|
|
1189
|
+
subject: queued.subject,
|
|
1190
|
+
text: queued.text,
|
|
1191
|
+
html: queued.html,
|
|
1192
|
+
});
|
|
1193
|
+
const status: EmailStatus = result.status === "failed" ? "failed" : "sent";
|
|
1194
|
+
const updated: EmailRecord = {
|
|
1195
|
+
...queued,
|
|
1196
|
+
status,
|
|
1197
|
+
updatedAt: new Date().toISOString(),
|
|
1198
|
+
meta: {
|
|
1199
|
+
...queued.meta,
|
|
1200
|
+
...providerMeta(provider, result),
|
|
1201
|
+
},
|
|
1202
|
+
};
|
|
1203
|
+
await serverDb.put(updated.dbKey, updated);
|
|
1204
|
+
if (status !== "failed") {
|
|
1205
|
+
await mirrorEmailToControlledInboxes({
|
|
1206
|
+
from: queued.from,
|
|
1207
|
+
to: queued.to,
|
|
1208
|
+
cc: queued.cc,
|
|
1209
|
+
bcc: queued.bcc,
|
|
1210
|
+
replyTo: queued.replyTo,
|
|
1211
|
+
subject: queued.subject,
|
|
1212
|
+
text: queued.text,
|
|
1213
|
+
html: queued.html,
|
|
1214
|
+
providerName: provider.name,
|
|
1215
|
+
providerMessageId: result.providerMessageId,
|
|
1216
|
+
meta: {
|
|
1217
|
+
source: "sendEmail",
|
|
1218
|
+
sentRecordKey: updated.dbKey,
|
|
1219
|
+
},
|
|
1220
|
+
});
|
|
1221
|
+
}
|
|
1222
|
+
return updated;
|
|
1223
|
+
} catch (error: any) {
|
|
1224
|
+
const failed: EmailRecord = {
|
|
1225
|
+
...queued,
|
|
1226
|
+
status: "failed",
|
|
1227
|
+
updatedAt: new Date().toISOString(),
|
|
1228
|
+
meta: {
|
|
1229
|
+
...queued.meta,
|
|
1230
|
+
provider: {
|
|
1231
|
+
name: provider.name,
|
|
1232
|
+
error: error?.message || String(error),
|
|
1233
|
+
},
|
|
1234
|
+
},
|
|
1235
|
+
};
|
|
1236
|
+
await serverDb.put(failed.dbKey, failed);
|
|
1237
|
+
return failed;
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
export async function receiveInboundEmail(
|
|
1242
|
+
payload: unknown,
|
|
1243
|
+
provider: EmailProvider = getEmailProvider()
|
|
1244
|
+
): Promise<ReceiveInboundEmailResult> {
|
|
1245
|
+
const inbound = await provider.parseInbound(payload);
|
|
1246
|
+
const records: EmailRecord[] = [];
|
|
1247
|
+
const existing: EmailRecord[] = [];
|
|
1248
|
+
const unresolved: string[] = [];
|
|
1249
|
+
|
|
1250
|
+
for (const recipient of inbound.to) {
|
|
1251
|
+
const agent = await findAgentByEmailAddress(recipient.email);
|
|
1252
|
+
if (!agent?.dbKey) {
|
|
1253
|
+
unresolved.push(recipient.email);
|
|
1254
|
+
continue;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
const existingRecord = await findExistingInboundEmail(
|
|
1258
|
+
agent.dbKey,
|
|
1259
|
+
inbound.messageId,
|
|
1260
|
+
inbound.providerMessageId
|
|
1261
|
+
);
|
|
1262
|
+
if (existingRecord) {
|
|
1263
|
+
existing.push(existingRecord);
|
|
1264
|
+
continue;
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
const now = inbound.providerReceivedAt || new Date().toISOString();
|
|
1268
|
+
const record = createEmailRecord({
|
|
1269
|
+
ownerType: "agent",
|
|
1270
|
+
ownerId: agent.dbKey,
|
|
1271
|
+
tenantId: resolveKeyOwnerId(agent.dbKey) || agent.ownerId || agent.dbKey,
|
|
1272
|
+
mailbox: "inbox",
|
|
1273
|
+
status: "received",
|
|
1274
|
+
from: inbound.from,
|
|
1275
|
+
to: [recipient],
|
|
1276
|
+
cc: inbound.cc,
|
|
1277
|
+
bcc: inbound.bcc,
|
|
1278
|
+
replyTo: inbound.replyTo,
|
|
1279
|
+
subject: inbound.subject,
|
|
1280
|
+
text: inbound.text,
|
|
1281
|
+
html: inbound.html,
|
|
1282
|
+
messageId: inbound.messageId,
|
|
1283
|
+
meta: {
|
|
1284
|
+
provider: {
|
|
1285
|
+
name: provider.name,
|
|
1286
|
+
messageId: inbound.providerMessageId,
|
|
1287
|
+
receivedAt: now,
|
|
1288
|
+
rawHeaders: inbound.rawHeaders,
|
|
1289
|
+
raw: inbound.raw,
|
|
1290
|
+
},
|
|
1291
|
+
},
|
|
1292
|
+
now,
|
|
1293
|
+
});
|
|
1294
|
+
await serverDb.put(record.dbKey, record);
|
|
1295
|
+
records.push(record);
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
if (unresolved.length > 0 && records.length === 0 && existing.length === 0) {
|
|
1299
|
+
throw apiError("not_found", `No agent email identity found for ${unresolved.join(", ")}`);
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
return { records, existing };
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
export async function bindAgentEmailIdentity(
|
|
1306
|
+
params: BindAgentEmailIdentityParams,
|
|
1307
|
+
context?: ApiContext
|
|
1308
|
+
) {
|
|
1309
|
+
if (!context?.userId) {
|
|
1310
|
+
throw apiError("unauthorized", "Authentication required");
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
const agentId = params?.agentId?.trim() || "";
|
|
1314
|
+
const emailAddress = normalizeEmailAddress(params?.emailAddress);
|
|
1315
|
+
assertValidEmailAddress(emailAddress);
|
|
1316
|
+
|
|
1317
|
+
const agent = await loadOwnedAgent(agentId, context.userId);
|
|
1318
|
+
const existing = await findAgentByEmailAddress(emailAddress);
|
|
1319
|
+
if (existing?.dbKey && existing.dbKey !== agentId) {
|
|
1320
|
+
throw apiError("invalid_input", "emailAddress is already bound to another agent");
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
const now = new Date().toISOString();
|
|
1324
|
+
const updated = upsertAgentEmailIdentity(
|
|
1325
|
+
agent,
|
|
1326
|
+
{
|
|
1327
|
+
emailAddress,
|
|
1328
|
+
provider: params.provider || agent.meta?.emailProvider || process.env.EMAIL_PROVIDER || "mock",
|
|
1329
|
+
createdAt: now,
|
|
1330
|
+
verifiedAt: now,
|
|
1331
|
+
readinessStatus: "warming",
|
|
1332
|
+
ingressReadyAt: null,
|
|
1333
|
+
lastWarmupAt: now,
|
|
1334
|
+
lastWarmupError: null,
|
|
1335
|
+
source: "bound",
|
|
1336
|
+
},
|
|
1337
|
+
{ makePrimary: true }
|
|
1338
|
+
);
|
|
1339
|
+
|
|
1340
|
+
await ensureCloudflareEmailRoutingRule(emailAddress, params.provider);
|
|
1341
|
+
await serverDb.put(agentId, updated);
|
|
1342
|
+
try {
|
|
1343
|
+
const ingressReadyAt = await waitForCloudflareRouteReadiness(
|
|
1344
|
+
agentId,
|
|
1345
|
+
emailAddress,
|
|
1346
|
+
extractDomainFromEmailValue(emailAddress),
|
|
1347
|
+
params.provider || updated.meta?.emailProvider
|
|
1348
|
+
);
|
|
1349
|
+
const readyAgent = upsertAgentEmailIdentity(
|
|
1350
|
+
updated,
|
|
1351
|
+
withReadinessState(getAgentEmailIdentity(updated, emailAddress) || { emailAddress }, {
|
|
1352
|
+
readinessStatus: "ready",
|
|
1353
|
+
ingressReadyAt,
|
|
1354
|
+
lastWarmupAt: new Date().toISOString(),
|
|
1355
|
+
lastWarmupError: null,
|
|
1356
|
+
}),
|
|
1357
|
+
{ makePrimary: true }
|
|
1358
|
+
);
|
|
1359
|
+
await serverDb.put(agentId, readyAgent);
|
|
1360
|
+
await ensureAgentEmailDelegation(context.userId, agentId);
|
|
1361
|
+
|
|
1362
|
+
return buildBindIdentityResult(agentId, emailAddress, readyAgent);
|
|
1363
|
+
} catch (error: any) {
|
|
1364
|
+
const failedWarmupAgent = upsertAgentEmailIdentity(
|
|
1365
|
+
updated,
|
|
1366
|
+
withReadinessState(getAgentEmailIdentity(updated, emailAddress) || { emailAddress }, {
|
|
1367
|
+
readinessStatus: "failed_warmup",
|
|
1368
|
+
ingressReadyAt: null,
|
|
1369
|
+
lastWarmupAt: new Date().toISOString(),
|
|
1370
|
+
lastWarmupError: error?.message || String(error),
|
|
1371
|
+
}),
|
|
1372
|
+
{ makePrimary: true }
|
|
1373
|
+
);
|
|
1374
|
+
await serverDb.put(agentId, failedWarmupAgent);
|
|
1375
|
+
await ensureAgentEmailDelegation(context.userId, agentId);
|
|
1376
|
+
return buildBindIdentityResult(agentId, emailAddress, failedWarmupAgent);
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
export async function provisionAgentEmailIdentity(
|
|
1381
|
+
params: ProvisionAgentEmailIdentityParams,
|
|
1382
|
+
context?: ApiContext
|
|
1383
|
+
) {
|
|
1384
|
+
if (!context?.userId) {
|
|
1385
|
+
throw apiError("unauthorized", "Authentication required");
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
const agentId = params?.agentId?.trim() || "";
|
|
1389
|
+
const agent = await loadOwnedAgent(agentId, context.userId);
|
|
1390
|
+
const domain = resolveAgentEmailDomain(params?.domain);
|
|
1391
|
+
const localPart = normalizeLocalPart(params?.localPart) ||
|
|
1392
|
+
buildProvisionedLocalPart(agentId, params?.purpose);
|
|
1393
|
+
assertValidLocalPart(localPart);
|
|
1394
|
+
|
|
1395
|
+
const emailAddress = `${localPart}@${domain}`;
|
|
1396
|
+
assertValidEmailAddress(emailAddress);
|
|
1397
|
+
|
|
1398
|
+
const existing = await findAgentByEmailAddress(emailAddress);
|
|
1399
|
+
if (existing?.dbKey && existing.dbKey !== agentId) {
|
|
1400
|
+
throw apiError("invalid_input", "emailAddress is already bound to another agent");
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
const now = new Date().toISOString();
|
|
1404
|
+
const provider =
|
|
1405
|
+
params?.provider ||
|
|
1406
|
+
agent.meta?.emailProvider ||
|
|
1407
|
+
process.env.EMAIL_PROVIDER ||
|
|
1408
|
+
DEFAULT_AGENT_EMAIL_PROVIDER;
|
|
1409
|
+
const updated = upsertAgentEmailIdentity(
|
|
1410
|
+
agent,
|
|
1411
|
+
{
|
|
1412
|
+
emailAddress,
|
|
1413
|
+
provider,
|
|
1414
|
+
purpose: normalizePurpose(params?.purpose) || undefined,
|
|
1415
|
+
createdAt: now,
|
|
1416
|
+
verifiedAt: now,
|
|
1417
|
+
readinessStatus: "warming",
|
|
1418
|
+
ingressReadyAt: null,
|
|
1419
|
+
lastWarmupAt: now,
|
|
1420
|
+
lastWarmupError: null,
|
|
1421
|
+
source: "provisioned",
|
|
1422
|
+
},
|
|
1423
|
+
{ makePrimary: params?.makePrimary }
|
|
1424
|
+
);
|
|
1425
|
+
|
|
1426
|
+
await ensureCloudflareEmailRoutingRule(emailAddress, provider);
|
|
1427
|
+
await serverDb.put(agentId, updated);
|
|
1428
|
+
try {
|
|
1429
|
+
const ingressReadyAt = await waitForCloudflareRouteReadiness(
|
|
1430
|
+
agentId,
|
|
1431
|
+
emailAddress,
|
|
1432
|
+
domain,
|
|
1433
|
+
provider
|
|
1434
|
+
);
|
|
1435
|
+
const readyAgent = upsertAgentEmailIdentity(
|
|
1436
|
+
updated,
|
|
1437
|
+
withReadinessState(getAgentEmailIdentity(updated, emailAddress) || { emailAddress }, {
|
|
1438
|
+
readinessStatus: "ready",
|
|
1439
|
+
ingressReadyAt,
|
|
1440
|
+
lastWarmupAt: new Date().toISOString(),
|
|
1441
|
+
lastWarmupError: null,
|
|
1442
|
+
}),
|
|
1443
|
+
{ makePrimary: params?.makePrimary }
|
|
1444
|
+
);
|
|
1445
|
+
await serverDb.put(agentId, readyAgent);
|
|
1446
|
+
await ensureAgentEmailDelegation(context.userId, agentId);
|
|
1447
|
+
|
|
1448
|
+
return buildProvisionIdentityResult(
|
|
1449
|
+
agentId,
|
|
1450
|
+
emailAddress,
|
|
1451
|
+
localPart,
|
|
1452
|
+
domain,
|
|
1453
|
+
provider,
|
|
1454
|
+
normalizePurpose(params?.purpose) || null,
|
|
1455
|
+
readyAgent
|
|
1456
|
+
);
|
|
1457
|
+
} catch (error: any) {
|
|
1458
|
+
const failedWarmupAgent = upsertAgentEmailIdentity(
|
|
1459
|
+
updated,
|
|
1460
|
+
withReadinessState(getAgentEmailIdentity(updated, emailAddress) || { emailAddress }, {
|
|
1461
|
+
readinessStatus: "failed_warmup",
|
|
1462
|
+
ingressReadyAt: null,
|
|
1463
|
+
lastWarmupAt: new Date().toISOString(),
|
|
1464
|
+
lastWarmupError: error?.message || String(error),
|
|
1465
|
+
}),
|
|
1466
|
+
{ makePrimary: params?.makePrimary }
|
|
1467
|
+
);
|
|
1468
|
+
await serverDb.put(agentId, failedWarmupAgent);
|
|
1469
|
+
await ensureAgentEmailDelegation(context.userId, agentId);
|
|
1470
|
+
return buildProvisionIdentityResult(
|
|
1471
|
+
agentId,
|
|
1472
|
+
emailAddress,
|
|
1473
|
+
localPart,
|
|
1474
|
+
domain,
|
|
1475
|
+
provider,
|
|
1476
|
+
normalizePurpose(params?.purpose) || null,
|
|
1477
|
+
failedWarmupAgent
|
|
1478
|
+
);
|
|
1479
|
+
}
|
|
1480
|
+
}
|