diff --git a/services/platform/convex/integrations/credentials_schema.ts b/services/platform/convex/integrations/credentials_schema.ts index d548acbe1f..9c0d0cff36 100644 --- a/services/platform/convex/integrations/credentials_schema.ts +++ b/services/platform/convex/integrations/credentials_schema.ts @@ -2,6 +2,7 @@ import { defineTable } from 'convex/server'; import { v } from 'convex/values'; import { jsonRecordValidator } from '../lib/validators/json'; +import { imapSmtpAccountEncryptedValidator } from './validators'; /** * Slim credentials table for installed integrations. @@ -60,6 +61,12 @@ export const integrationCredentialsTable = defineTable({ passwordEncrypted: v.string(), }), ), + // Multiple real mailbox accounts for imap_smtp integrations — each with its + // own IMAP login, SMTP/relay target, From address and routing. Optional: when + // absent the integration behaves as a single mailbox via the legacy + // basicAuth/smtpAuth/connectionConfig fields above. Adding an optional field + // is data-safe growth (snapshot regen, no data migration). + imapSmtpAccounts: v.optional(v.array(imapSmtpAccountEncryptedValidator)), oauth2Auth: v.optional( v.object({ accessTokenEncrypted: v.string(), diff --git a/services/platform/convex/integrations/imap_smtp_config.test.ts b/services/platform/convex/integrations/imap_smtp_config.test.ts index 81b5b2bd2e..d0465895b7 100644 --- a/services/platform/convex/integrations/imap_smtp_config.test.ts +++ b/services/platform/convex/integrations/imap_smtp_config.test.ts @@ -113,3 +113,121 @@ describe('resolveImapSmtpConnection', () => { ).rejects.toThrow(/IMAP host/i); }); }); + +// Two real mailbox accounts under one integration: `support@` (SMTP reuses the +// mailbox login) and `billing@` (a distinct SMTP relay). Hosts/ports/TLS are +// typed on the account, so no string parsing is involved. +const MULTI_ACCOUNTS = [ + { + id: 'support', + displayName: 'Support', + fromAddress: 'support@acme.com', + imapHost: 'imap.acme.com', + imapPort: 993, + imapSecure: true, + smtpHost: 'smtp.acme.com', + smtpPort: 587, + smtpSecure: false, + isDefault: true, + imapAuth: { + username: 'support@acme.com', + passwordEncrypted: 'SUPPORT_IMAP', + }, + }, + { + id: 'billing', + displayName: 'Billing', + fromAddress: 'billing@acme.com', + imapHost: 'imap.acme.com', + imapPort: 993, + imapSecure: true, + smtpHost: 'smtp.sendgrid.net', + smtpPort: 2525, + smtpSecure: false, + imapAuth: { + username: 'billing@acme.com', + passwordEncrypted: 'BILLING_IMAP', + }, + smtpAuth: { username: 'apikey', passwordEncrypted: 'SENDGRID_KEY' }, + }, +]; + +describe('resolveImapSmtpConnection — multi-account', () => { + it('selects a specific account by accountId, IMAP + distinct SMTP relay', async () => { + const conn = await resolveImapSmtpConnection( + makeCtx(), + makeIntegration({ imapSmtpAccounts: MULTI_ACCOUNTS }), + { accountId: 'billing' }, + ); + + expect(conn.imap.host).toBe('imap.acme.com'); + expect(conn.imap.user).toBe('billing@acme.com'); + expect(conn.imap.password).toBe('dec(BILLING_IMAP)'); + // The distinct SMTP relay login (provider-agnostic — here a SendGrid relay). + expect(conn.smtp.host).toBe('smtp.sendgrid.net'); + expect(conn.smtp.port).toBe(2525); + expect(conn.smtp.user).toBe('apikey'); + expect(conn.smtp.password).toBe('dec(SENDGRID_KEY)'); + }); + + it('defaults to the isDefault account and reuses IMAP login for SMTP', async () => { + const conn = await resolveImapSmtpConnection( + makeCtx(), + makeIntegration({ imapSmtpAccounts: MULTI_ACCOUNTS }), + ); + + expect(conn.imap.user).toBe('support@acme.com'); + // support has no smtpAuth → SMTP falls back to the mailbox login. + expect(conn.smtp.user).toBe('support@acme.com'); + expect(conn.smtp.password).toBe('dec(SUPPORT_IMAP)'); + // Typed host/port/secure straight off the account — no string parsing. + expect(conn.imap.port).toBe(993); + expect(conn.imap.secure).toBe(true); + expect(conn.smtp.port).toBe(587); + expect(conn.smtp.secure).toBe(false); + }); + + it('falls back to the first account when none is marked default', async () => { + const noDefault = MULTI_ACCOUNTS.map((account) => ({ + ...account, + isDefault: false, + })); + const conn = await resolveImapSmtpConnection( + makeCtx(), + makeIntegration({ imapSmtpAccounts: noDefault }), + ); + + expect(conn.imap.user).toBe('support@acme.com'); + }); + + it('throws when the requested accountId is not configured', async () => { + await expect( + resolveImapSmtpConnection( + makeCtx(), + makeIntegration({ imapSmtpAccounts: MULTI_ACCOUNTS }), + { accountId: 'nope' }, + ), + ).rejects.toThrow(/no mailbox account/i); + }); + + it('honours a legacy pre-save dry-run even when accounts are stored', async () => { + // A pre-save "Test" passes inline legacy credentials/config; the dry-run + // path is honoured over the stored accounts so the form can be validated + // before it is saved. + const conn = await resolveImapSmtpConnection( + makeCtx(), + makeIntegration({ imapSmtpAccounts: MULTI_ACCOUNTS }), + { + basicAuth: { username: 'dry@acme.com', password: 'dry-pass' }, + connectionConfig: { + imapHost: 'imap.dry.com', + smtpHost: 'smtp.dry.com', + }, + }, + ); + + expect(conn.imap.host).toBe('imap.dry.com'); + expect(conn.imap.user).toBe('dry@acme.com'); + expect(conn.imap.password).toBe('dry-pass'); + }); +}); diff --git a/services/platform/convex/integrations/imap_smtp_config.ts b/services/platform/convex/integrations/imap_smtp_config.ts index ffe9269f5f..55fe4dff58 100644 --- a/services/platform/convex/integrations/imap_smtp_config.ts +++ b/services/platform/convex/integrations/imap_smtp_config.ts @@ -33,6 +33,11 @@ interface Overrides { clearSmtpAuth?: boolean; /** Inline connection config for pre-save testing. */ connectionConfig?: Record; + /** + * Select a specific stored mailbox account by id (multi-account + * integrations). Omitted ⇒ the account marked `isDefault`, else the first. + */ + accountId?: string; } interface CredPair { @@ -93,11 +98,89 @@ function requireString(value: unknown): string | undefined { : undefined; } +type StoredImapSmtpAccount = NonNullable< + LoadedIntegration['imapSmtpAccounts'] +>[number]; + +/** + * Pick the mailbox account to act on: an explicit id when given, otherwise the + * account flagged `isDefault`, otherwise the first configured account. + */ +function selectImapSmtpAccount( + accounts: readonly StoredImapSmtpAccount[], + accountId: string | undefined, +): StoredImapSmtpAccount { + if (accountId) { + const match = accounts.find((account) => account.id === accountId); + if (!match) { + throw new Error( + `No mailbox account "${accountId}" is configured on this integration.`, + ); + } + return match; + } + return accounts.find((account) => account.isDefault) ?? accounts[0]; +} + +/** + * Resolve a connection from one stored account. Hosts/ports/TLS are already + * typed on the account (no string parsing); IMAP uses the account's own login + * and SMTP reuses it unless the account carries a distinct `smtpAuth`. + */ +async function resolveConnectionFromAccount( + ctx: ActionCtx, + account: StoredImapSmtpAccount, + clearSmtpAuth: boolean, +): Promise { + const imapCreds = await resolveCredPair( + ctx, + undefined, + account.imapAuth, + 'mailbox', + ); + const useSeparateSmtp = !clearSmtpAuth && Boolean(account.smtpAuth); + const smtpCreds = useSeparateSmtp + ? await resolveCredPair(ctx, undefined, account.smtpAuth, 'SMTP') + : imapCreds; + return { + imap: { + host: account.imapHost, + port: account.imapPort, + secure: account.imapSecure, + user: imapCreds.user, + password: imapCreds.password, + }, + smtp: { + host: account.smtpHost, + port: account.smtpPort, + secure: account.smtpSecure, + user: smtpCreds.user, + password: smtpCreds.password, + }, + }; +} + export async function resolveImapSmtpConnection( ctx: ActionCtx, integration: LoadedIntegration, overrides?: Overrides, ): Promise { + // Multi-account integrations store one or more real mailboxes. Absent (or a + // pre-save dry-run that passes inline legacy credentials/config) ⇒ fall + // through to the single-mailbox path below, byte-for-byte today's behavior. + const accounts = integration.imapSmtpAccounts; + const hasLegacyDryRun = Boolean( + overrides?.basicAuth || overrides?.smtpAuth || overrides?.connectionConfig, + ); + if (accounts && accounts.length > 0 && !hasLegacyDryRun) { + const account = selectImapSmtpAccount(accounts, overrides?.accountId); + return resolveConnectionFromAccount( + ctx, + account, + Boolean(overrides?.clearSmtpAuth), + ); + } + // Merge stored connection config with any inline (dry-run) override. const config: Record = { // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- connectionConfig is v.any() with catchall keys diff --git a/services/platform/convex/integrations/load_integration.ts b/services/platform/convex/integrations/load_integration.ts index fa495e2f9d..5e06743e45 100644 --- a/services/platform/convex/integrations/load_integration.ts +++ b/services/platform/convex/integrations/load_integration.ts @@ -37,6 +37,25 @@ export interface LoadedIntegration { // Optional SMTP-only credential (imap_smtp): used for sending when it differs // from the IMAP login. Falls back to basicAuth when absent. smtpAuth?: { username: string; passwordEncrypted: string }; + // Multiple real mailbox accounts (imap_smtp). Absent ⇒ legacy single mailbox + // via basicAuth/smtpAuth/connectionConfig above. + imapSmtpAccounts?: Array<{ + id: string; + displayName?: string; + fromAddress: string; + imapHost: string; + imapPort: number; + imapSecure: boolean; + smtpHost: string; + smtpPort: number; + smtpSecure: boolean; + sentMailbox?: string; + saveSentToImap?: boolean; + isDefault?: boolean; + routing?: { teamId?: string; userId?: string }; + imapAuth: { username: string; passwordEncrypted: string }; + smtpAuth?: { username: string; passwordEncrypted: string }; + }>; oauth2Auth?: { accessTokenEncrypted: string; refreshTokenEncrypted?: string; @@ -271,6 +290,7 @@ export const loadIntegration = internalAction({ apiKeyAuth: credentials.apiKeyAuth, basicAuth: credentials.basicAuth, smtpAuth: credentials.smtpAuth, + imapSmtpAccounts: credentials.imapSmtpAccounts, oauth2Auth: credentials.oauth2Auth, oauth2Config, // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- connectionConfig is v.any() in schema diff --git a/services/platform/convex/integrations/types.ts b/services/platform/convex/integrations/types.ts index 23ab1b9c0d..4354efa77c 100644 --- a/services/platform/convex/integrations/types.ts +++ b/services/platform/convex/integrations/types.ts @@ -15,6 +15,8 @@ import type { connectionConfigValidator, connectorConfigValidator, connectorOperationValidator, + imapSmtpAccountEncryptedValidator, + imapSmtpAccountValidator, integrationTypeValidator, oauth2AuthEncryptedValidator, oauth2AuthValidator, @@ -44,6 +46,10 @@ export type BasicAuth = Infer; export type BasicAuthEncrypted = Infer; export type SmtpAuth = Infer; export type SmtpAuthEncrypted = Infer; +export type ImapSmtpAccount = Infer; +export type ImapSmtpAccountEncrypted = Infer< + typeof imapSmtpAccountEncryptedValidator +>; export type OAuth2Auth = Infer; export type OAuth2AuthEncrypted = Infer; diff --git a/services/platform/convex/integrations/validators.ts b/services/platform/convex/integrations/validators.ts index 7be4b1a9ad..76033e64d1 100644 --- a/services/platform/convex/integrations/validators.ts +++ b/services/platform/convex/integrations/validators.ts @@ -74,6 +74,64 @@ export const smtpAuthEncryptedValidator = v.object({ passwordEncrypted: v.string(), }); +/** Per-account inbound routing target (mirrors the governance rule shape). */ +const imapSmtpAccountRoutingValidator = v.object({ + teamId: v.optional(v.string()), + userId: v.optional(v.string()), +}); + +/** + * One real mailbox account under an imap_smtp integration. An integration may + * hold several accounts; each has its own IMAP login, its own SMTP/relay target + * (the mailbox's own SMTP, any relay, or a self-hosted submission host — the + * design is provider-agnostic), its own From address, and its own routing. + * Plaintext form (input to `saveCredentials`); the passwords are encrypted at + * rest in {@link imapSmtpAccountEncryptedValidator}. + */ +export const imapSmtpAccountValidator = v.object({ + /** Stable id tying descriptor ↔ stored credentials ↔ conversation.accountId. */ + id: v.string(), + /** Label shown in the inbox filter, compose picker and thread header. */ + displayName: v.optional(v.string()), + /** The exact From / reply-from address for this account. */ + fromAddress: v.string(), + imapHost: v.string(), + imapPort: v.number(), + imapSecure: v.boolean(), + smtpHost: v.string(), + smtpPort: v.number(), + smtpSecure: v.boolean(), + /** Sent-folder name to append copies to (discovered when omitted). */ + sentMailbox: v.optional(v.string()), + saveSentToImap: v.optional(v.boolean()), + /** The notifications/compose default account (at most one per integration). */ + isDefault: v.optional(v.boolean()), + routing: v.optional(imapSmtpAccountRoutingValidator), + /** IMAP (receiving) login. */ + imapAuth: basicAuthValidator, + /** SMTP (sending) login when it differs from imapAuth; absent ⇒ reuse it. */ + smtpAuth: v.optional(smtpAuthValidator), +}); + +/** Stored (encrypted-at-rest) form of {@link imapSmtpAccountValidator}. */ +export const imapSmtpAccountEncryptedValidator = v.object({ + id: v.string(), + displayName: v.optional(v.string()), + fromAddress: v.string(), + imapHost: v.string(), + imapPort: v.number(), + imapSecure: v.boolean(), + smtpHost: v.string(), + smtpPort: v.number(), + smtpSecure: v.boolean(), + sentMailbox: v.optional(v.string()), + saveSentToImap: v.optional(v.boolean()), + isDefault: v.optional(v.boolean()), + routing: v.optional(imapSmtpAccountRoutingValidator), + imapAuth: basicAuthEncryptedValidator, + smtpAuth: v.optional(smtpAuthEncryptedValidator), +}); + export const oauth2AuthValidator = v.object({ accessToken: v.string(), refreshToken: v.optional(v.string()),