mirror of
https://github.com/documenso/documenso.git
synced 2026-07-25 01:15:49 +10:00
fix: add multi email transport system (#2942)
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user