fix: add multi email transport system (#2942)

This commit is contained in:
David Nguyen
2026-06-05 21:19:20 +10:00
committed by GitHub
parent ebf5b75a19
commit 4ee789ea37
67 changed files with 2440 additions and 115 deletions
@@ -23,6 +23,7 @@ export const createSubscriptionClaimRoute = adminProcedure
emailQuota,
apiRateLimits,
apiQuota,
emailTransportId,
} = input;
ctx.logger.info({
@@ -43,6 +44,7 @@ export const createSubscriptionClaimRoute = adminProcedure
emailQuota,
apiRateLimits,
apiQuota,
emailTransportId,
},
});
});
@@ -17,6 +17,8 @@ export const ZCreateSubscriptionClaimRequestSchema = z.object({
apiRateLimits: ZRateLimitArraySchema,
apiQuota: z.number().int().min(0).nullable(),
emailTransportId: z.string().nullable(),
});
export const ZCreateSubscriptionClaimResponseSchema = z.void();
@@ -0,0 +1,32 @@
import { encryptEmailTransportConfig } from '@documenso/lib/server-only/email/email-transport-config';
import { generateDatabaseId } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { adminProcedure } from '../../trpc';
import {
ZCreateEmailTransportRequestSchema,
ZCreateEmailTransportResponseSchema,
} from './create-email-transport.types';
export const createEmailTransportRoute = adminProcedure
.input(ZCreateEmailTransportRequestSchema)
.output(ZCreateEmailTransportResponseSchema)
.mutation(async ({ input }) => {
const { name, fromName, fromAddress, config } = input;
const transport = await prisma.emailTransport.create({
data: {
id: generateDatabaseId('email_transport'),
name,
type: config.type,
fromName,
fromAddress,
config: encryptEmailTransportConfig(config),
},
select: { id: true },
});
return {
id: transport.id,
};
});
@@ -0,0 +1,15 @@
import { ZEmailTransportConfigSchema } from '@documenso/lib/server-only/email/email-transport-config';
import { z } from 'zod';
export const ZCreateEmailTransportRequestSchema = z.object({
name: z.string().min(1),
fromName: z.string().min(1),
fromAddress: z.string().email(),
config: ZEmailTransportConfigSchema,
});
export const ZCreateEmailTransportResponseSchema = z.object({
id: z.string(),
});
export type TCreateEmailTransportRequest = z.infer<typeof ZCreateEmailTransportRequestSchema>;
@@ -0,0 +1,24 @@
import { prisma } from '@documenso/prisma';
import { adminProcedure } from '../../trpc';
import {
ZDeleteEmailTransportRequestSchema,
ZDeleteEmailTransportResponseSchema,
} from './delete-email-transport.types';
export const deleteEmailTransportRoute = adminProcedure
.input(ZDeleteEmailTransportRequestSchema)
.output(ZDeleteEmailTransportResponseSchema)
.mutation(async ({ input, ctx }) => {
ctx.logger.info({
input: {
id: input.id,
},
});
await prisma.emailTransport.delete({
where: {
id: input.id,
},
});
});
@@ -0,0 +1,7 @@
import { z } from 'zod';
export const ZDeleteEmailTransportRequestSchema = z.object({
id: z.string(),
});
export const ZDeleteEmailTransportResponseSchema = z.void();
@@ -0,0 +1,65 @@
import {
decryptEmailTransportConfig,
toPublicEmailTransportConfig,
} from '@documenso/lib/server-only/email/email-transport-config';
import { prisma } from '@documenso/prisma';
import { Prisma } from '@prisma/client';
import { adminProcedure } from '../../trpc';
import { ZFindEmailTransportsRequestSchema, ZFindEmailTransportsResponseSchema } from './find-email-transports.types';
export const findEmailTransportsRoute = adminProcedure
.input(ZFindEmailTransportsRequestSchema)
.output(ZFindEmailTransportsResponseSchema)
.query(async ({ input }) => {
const { query, page = 1, perPage = 20 } = input;
const where: Prisma.EmailTransportWhereInput = query
? {
OR: [
{ name: { contains: query, mode: Prisma.QueryMode.insensitive } },
{ fromAddress: { contains: query, mode: Prisma.QueryMode.insensitive } },
],
}
: {};
const [transports, count] = await Promise.all([
prisma.emailTransport.findMany({
where,
skip: (page - 1) * perPage,
take: perPage,
orderBy: { createdAt: 'desc' },
include: {
_count: {
select: { subscriptionClaims: true, organisationClaims: true },
},
},
}),
prisma.emailTransport.count({ where }),
]);
// Replace the encrypted `config` blob with the non-secret connection
// settings so the encrypted value (and secrets) never leave the server.
const data = transports.map(({ config, ...transport }) => {
let publicConfig: ReturnType<typeof toPublicEmailTransportConfig> | null = null;
try {
publicConfig = toPublicEmailTransportConfig(decryptEmailTransportConfig(config));
} catch {
publicConfig = null;
}
return {
...transport,
config: publicConfig,
};
});
return {
data,
count,
currentPage: page,
perPage,
totalPages: Math.ceil(count / perPage),
};
});
@@ -0,0 +1,31 @@
import { ZEmailTransportPublicConfigSchema } from '@documenso/lib/server-only/email/email-transport-config';
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
import EmailTransportSchema from '@documenso/prisma/generated/zod/modelSchema/EmailTransportSchema';
import { z } from 'zod';
export const ZFindEmailTransportsRequestSchema = ZFindSearchParamsSchema;
export const ZFindEmailTransportsResponseSchema = ZFindResultResponse.extend({
data: EmailTransportSchema.pick({
id: true,
name: true,
type: true,
fromName: true,
fromAddress: true,
createdAt: true,
updatedAt: true,
})
.extend({
_count: z.object({
subscriptionClaims: z.number(),
organisationClaims: z.number(),
}),
// Non-secret connection settings, so the edit form can pre-fill them.
// Null when the stored config can't be decrypted/parsed.
config: ZEmailTransportPublicConfigSchema.nullable(),
})
.array(),
});
export type TFindEmailTransportsRequest = z.infer<typeof ZFindEmailTransportsRequestSchema>;
export type TFindEmailTransportsResponse = z.infer<typeof ZFindEmailTransportsResponseSchema>;
@@ -0,0 +1,49 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { resolveEmailTransport } from '@documenso/lib/server-only/email/resolve-email-transport';
import { prisma } from '@documenso/prisma';
import { adminProcedure } from '../../trpc';
import {
ZSendTestEmailTransportRequestSchema,
ZSendTestEmailTransportResponseSchema,
} from './send-test-email-transport.types';
export const sendTestEmailTransportRoute = adminProcedure
.input(ZSendTestEmailTransportRequestSchema)
.output(ZSendTestEmailTransportResponseSchema)
.mutation(async ({ input, ctx }) => {
ctx.logger.info({
input: {
id: input.id,
},
});
const transport = await prisma.emailTransport.findUnique({
where: {
id: input.id,
},
});
if (!transport) {
throw new AppError(AppErrorCode.NOT_FOUND, { message: 'Email transport not found' });
}
const resolved = await resolveEmailTransport(input.id);
if (!resolved) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Failed to build transport from stored configuration.',
});
}
try {
await resolved.transporter.sendMail({
to: input.to,
from: { name: transport.fromName, address: transport.fromAddress },
subject: 'Documenso email transport test',
text: `This is a test email sent through the "${transport.name}" email transport.`,
});
} catch (err) {
throw AppError.parseError(err);
}
});
@@ -0,0 +1,10 @@
import { z } from 'zod';
export const ZSendTestEmailTransportRequestSchema = z.object({
id: z.string(),
to: z.string().email(),
});
export const ZSendTestEmailTransportResponseSchema = z.void();
export type TSendTestEmailTransportResponse = z.infer<typeof ZSendTestEmailTransportResponseSchema>;
@@ -0,0 +1,57 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import {
decryptEmailTransportConfig,
EMAIL_TRANSPORT_SECRET_KEYS,
encryptEmailTransportConfig,
ZEmailTransportConfigSchema,
} from '@documenso/lib/server-only/email/email-transport-config';
import { prisma } from '@documenso/prisma';
import { adminProcedure } from '../../trpc';
import {
ZUpdateEmailTransportRequestSchema,
ZUpdateEmailTransportResponseSchema,
} from './update-email-transport.types';
export const updateEmailTransportRoute = adminProcedure
.input(ZUpdateEmailTransportRequestSchema)
.output(ZUpdateEmailTransportResponseSchema)
.mutation(async ({ input }) => {
const { id, data } = input;
const existing = await prisma.emailTransport.findUnique({
where: { id },
});
if (!existing) {
throw new AppError(AppErrorCode.NOT_FOUND, { message: 'Email transport not found' });
}
const existingConfig = decryptEmailTransportConfig(existing.config);
// Start from the incoming config; backfill empty secret fields from the existing
// config (only when the type is unchanged).
const merged: Record<string, unknown> = { ...data.config };
if (existingConfig.type === data.config.type) {
for (const key of EMAIL_TRANSPORT_SECRET_KEYS) {
const incoming = (data.config as Record<string, unknown>)[key];
if (incoming === undefined || incoming === '') {
merged[key] = (existingConfig as Record<string, unknown>)[key];
}
}
}
const config = ZEmailTransportConfigSchema.parse(merged);
await prisma.emailTransport.update({
where: { id },
data: {
name: data.name,
type: config.type,
fromName: data.fromName,
fromAddress: data.fromAddress,
config: encryptEmailTransportConfig(config),
},
});
});
@@ -0,0 +1,33 @@
import {
ZMailChannelsConfigSchema,
ZResendConfigSchema,
ZSmtpApiConfigSchema,
ZSmtpAuthConfigSchema,
} from '@documenso/lib/server-only/email/email-transport-config';
import { z } from 'zod';
// Reuses the canonical transport config schemas, but relaxes the secret field so
// a blank/omitted value means "keep existing". Note: `.partial()` only makes the
// key optional — it keeps the `.min(1)` validator, so an empty string would be
// rejected. We override the secret field with a plain optional string instead.
// (SMTP_AUTH's `password` is already optional in the source schema.)
const ZUpdateConfigSchema = z.discriminatedUnion('type', [
ZSmtpAuthConfigSchema,
ZSmtpApiConfigSchema.extend({ apiKey: z.string().optional() }),
ZResendConfigSchema.extend({ apiKey: z.string().optional() }),
ZMailChannelsConfigSchema.extend({ apiKey: z.string().optional() }),
]);
export const ZUpdateEmailTransportRequestSchema = z.object({
id: z.string(),
data: z.object({
name: z.string().min(1),
fromName: z.string().min(1),
fromAddress: z.string().email(),
config: ZUpdateConfigSchema,
}),
});
export const ZUpdateEmailTransportResponseSchema = z.void();
export type TUpdateEmailTransportRequest = z.infer<typeof ZUpdateEmailTransportRequestSchema>;
@@ -22,6 +22,7 @@ export const ZFindSubscriptionClaimsResponseSchema = ZFindResultResponse.extend(
emailQuota: true,
apiRateLimits: true,
apiQuota: true,
emailTransportId: true,
}).array(),
});
@@ -11,6 +11,11 @@ import { deleteAdminTeamMemberRoute } from './delete-team-member';
import { deleteUserRoute } from './delete-user';
import { disableUserRoute } from './disable-user';
import { downloadDocumentAuditLogsRoute } from './download-document-audit-logs';
import { createEmailTransportRoute } from './email-transport/create-email-transport';
import { deleteEmailTransportRoute } from './email-transport/delete-email-transport';
import { findEmailTransportsRoute } from './email-transport/find-email-transports';
import { sendTestEmailTransportRoute } from './email-transport/send-test-email-transport';
import { updateEmailTransportRoute } from './email-transport/update-email-transport';
import { enableUserRoute } from './enable-user';
import { findAdminOrganisationsRoute } from './find-admin-organisations';
import { findDocumentAuditLogsRoute } from './find-document-audit-logs';
@@ -100,6 +105,13 @@ export const adminRouter = router({
get: getEmailDomainRoute,
reregister: reregisterEmailDomainRoute,
},
emailTransport: {
find: findEmailTransportsRoute,
create: createEmailTransportRoute,
update: updateEmailTransportRoute,
delete: deleteEmailTransportRoute,
sendTest: sendTestEmailTransportRoute,
},
team: {
get: getAdminTeamRoute,
},
@@ -21,6 +21,7 @@ export const ZUpdateAdminOrganisationRequestSchema = z.object({
emailQuota: true,
apiRateLimits: true,
apiQuota: true,
emailTransportId: true,
}).optional(),
customerId: z.string().optional(),
originalSubscriptionClaimId: z.string().optional(),
@@ -13,7 +13,7 @@ export const updateSubscriptionClaimRoute = adminProcedure
.input(ZUpdateSubscriptionClaimRequestSchema)
.output(ZUpdateSubscriptionClaimResponseSchema)
.mutation(async ({ input, ctx }) => {
const { id, data } = input;
const { id, data, backportEmailTransport } = input;
ctx.logger.info({
input,
@@ -36,6 +36,13 @@ export const updateSubscriptionClaimRoute = adminProcedure
data,
});
if (backportEmailTransport) {
await prisma.organisationClaim.updateMany({
where: { originalSubscriptionClaimId: id },
data: { emailTransportId: data.emailTransportId ?? null },
});
}
if (Object.keys(newlyEnabledFlags).length > 0) {
await jobsClient.triggerJob({
name: 'internal.backport-subscription-claims',
@@ -5,6 +5,9 @@ import { ZCreateSubscriptionClaimRequestSchema } from './create-subscription-cla
export const ZUpdateSubscriptionClaimRequestSchema = z.object({
id: z.string(),
data: ZCreateSubscriptionClaimRequestSchema,
// When enabled, the claim's email transport is propagated to all organisations
// currently using this claim.
backportEmailTransport: z.boolean().default(false),
});
export const ZUpdateSubscriptionClaimResponseSchema = z.void();