Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions services/platform/convex/integrations/credentials_schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(),
Expand Down
118 changes: 118 additions & 0 deletions services/platform/convex/integrations/imap_smtp_config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '[email protected]',
imapHost: 'imap.acme.com',
imapPort: 993,
imapSecure: true,
smtpHost: 'smtp.acme.com',
smtpPort: 587,
smtpSecure: false,
isDefault: true,
imapAuth: {
username: '[email protected]',
passwordEncrypted: 'SUPPORT_IMAP',
},
},
{
id: 'billing',
displayName: 'Billing',
fromAddress: '[email protected]',
imapHost: 'imap.acme.com',
imapPort: 993,
imapSecure: true,
smtpHost: 'smtp.sendgrid.net',
smtpPort: 2525,
smtpSecure: false,
imapAuth: {
username: '[email protected]',
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('[email protected]');
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('[email protected]');
// support has no smtpAuth → SMTP falls back to the mailbox login.
expect(conn.smtp.user).toBe('[email protected]');
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('[email protected]');
});

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: '[email protected]', 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('[email protected]');
expect(conn.imap.password).toBe('dry-pass');
});
});
83 changes: 83 additions & 0 deletions services/platform/convex/integrations/imap_smtp_config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ interface Overrides {
clearSmtpAuth?: boolean;
/** Inline connection config for pre-save testing. */
connectionConfig?: Record<string, unknown>;
/**
* Select a specific stored mailbox account by id (multi-account
* integrations). Omitted ⇒ the account marked `isDefault`, else the first.
*/
accountId?: string;
}

interface CredPair {
Expand Down Expand Up @@ -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<ImapSmtpConnection> {
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<ImapSmtpConnection> {
// 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<string, unknown> = {
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- connectionConfig is v.any() with catchall keys
Expand Down
20 changes: 20 additions & 0 deletions services/platform/convex/integrations/load_integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions services/platform/convex/integrations/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import type {
connectionConfigValidator,
connectorConfigValidator,
connectorOperationValidator,
imapSmtpAccountEncryptedValidator,
imapSmtpAccountValidator,
integrationTypeValidator,
oauth2AuthEncryptedValidator,
oauth2AuthValidator,
Expand Down Expand Up @@ -44,6 +46,10 @@ export type BasicAuth = Infer<typeof basicAuthValidator>;
export type BasicAuthEncrypted = Infer<typeof basicAuthEncryptedValidator>;
export type SmtpAuth = Infer<typeof smtpAuthValidator>;
export type SmtpAuthEncrypted = Infer<typeof smtpAuthEncryptedValidator>;
export type ImapSmtpAccount = Infer<typeof imapSmtpAccountValidator>;
export type ImapSmtpAccountEncrypted = Infer<
typeof imapSmtpAccountEncryptedValidator
>;
export type OAuth2Auth = Infer<typeof oauth2AuthValidator>;
export type OAuth2AuthEncrypted = Infer<typeof oauth2AuthEncryptedValidator>;

Expand Down
58 changes: 58 additions & 0 deletions services/platform/convex/integrations/validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand Down
Loading