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
@@ -2,6 +2,7 @@ import type { TLicenseClaim } from '@documenso/lib/types/license';
import { trpc } from '@documenso/trpc/react';
import type { TFindSubscriptionClaimsResponse } from '@documenso/trpc/server/admin-router/find-subscription-claims.types';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
Dialog,
DialogContent,
@@ -28,6 +29,7 @@ export const ClaimUpdateDialog = ({ claim, trigger, licenseFlags }: ClaimUpdateD
const { toast } = useToast();
const [open, setOpen] = useState(false);
const [backportEmailTransport, setBackportEmailTransport] = useState(false);
const { mutateAsync: updateClaim, isPending } = trpc.admin.claims.update.useMutation({
onSuccess: () => {
@@ -67,19 +69,33 @@ export const ClaimUpdateDialog = ({ claim, trigger, licenseFlags }: ClaimUpdateD
await updateClaim({
id: claim.id,
data,
backportEmailTransport,
})
}
licenseFlags={licenseFlags}
formSubmitTrigger={
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)} disabled={isPending}>
<Trans>Cancel</Trans>
</Button>
<>
<div className="flex items-center space-x-2">
<Checkbox
id="backport-email-transport"
checked={backportEmailTransport}
onCheckedChange={(checked) => setBackportEmailTransport(checked === true)}
/>
<label htmlFor="backport-email-transport" className="text-muted-foreground text-sm">
<Trans>Backport email transport</Trans>
</label>
</div>
<Button type="submit" loading={isPending}>
<Trans>Update Claim</Trans>
</Button>
</DialogFooter>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)} disabled={isPending}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={isPending}>
<Trans>Update Claim</Trans>
</Button>
</DialogFooter>
</>
}
/>
</DialogContent>
@@ -0,0 +1,95 @@
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Trans, useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import {
EmailTransportForm,
type EmailTransportFormValues,
emailTransportFormToConfig,
} from '../forms/email-transport-form';
export type EmailTransportCreateDialogProps = {
trigger?: React.ReactNode;
};
export const EmailTransportCreateDialog = ({ trigger }: EmailTransportCreateDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const [open, setOpen] = useState(false);
const { mutateAsync: createTransport, isPending } = trpc.admin.emailTransport.create.useMutation({
onSuccess: () => {
toast({
title: t`Transport created.`,
});
setOpen(false);
},
onError: (error) => {
toast({
title: t`Failed to create transport.`,
description: error.message,
variant: 'destructive',
});
},
});
const onFormSubmit = async (values: EmailTransportFormValues) => {
await createTransport({
name: values.name,
fromName: values.fromName,
fromAddress: values.fromAddress,
config: emailTransportFormToConfig(values),
});
};
return (
<Dialog open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
{trigger ?? (
<Button className="flex-shrink-0">
<Trans>Add transport</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent className="scrollbar-hidden max-h-[90vh] overflow-y-auto sm:max-w-md">
<DialogHeader>
<DialogTitle>
<Trans>Add Email Transport</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Fill in the details to create a new email transport.</Trans>
</DialogDescription>
</DialogHeader>
<EmailTransportForm
onFormSubmit={onFormSubmit}
formSubmitTrigger={
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)} disabled={isPending}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={isPending}>
<Trans>Create</Trans>
</Button>
</DialogFooter>
}
/>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,114 @@
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Plural, Trans, useLingui } from '@lingui/react/macro';
import { useState } from 'react';
export type EmailTransportDeleteDialogProps = {
transportId: string;
transportName: string;
subscriptionClaimCount: number;
organisationClaimCount: number;
trigger: React.ReactNode;
};
export const EmailTransportDeleteDialog = ({
transportId,
transportName,
subscriptionClaimCount,
organisationClaimCount,
trigger,
}: EmailTransportDeleteDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const [open, setOpen] = useState(false);
const isInUse = subscriptionClaimCount + organisationClaimCount > 0;
const { mutateAsync: deleteTransport, isPending } = trpc.admin.emailTransport.delete.useMutation({
onSuccess: () => {
toast({
title: t`Transport deleted.`,
});
setOpen(false);
},
onError: () => {
toast({
title: t`Failed to delete transport.`,
variant: 'destructive',
});
},
});
return (
<Dialog open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
<DialogTrigger asChild onClick={(e) => e.stopPropagation()}>
{trigger}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Delete Email Transport</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Are you sure you want to delete the following transport?</Trans>
</DialogDescription>
</DialogHeader>
<Alert variant="neutral">
<AlertDescription className="text-center font-semibold">{transportName}</AlertDescription>
</Alert>
{isInUse && (
<Alert variant="destructive">
<AlertDescription>
<Trans>Warning, this email transport is currently being used by:</Trans>
<ul className="mt-2 list-disc pl-5">
{subscriptionClaimCount > 0 && (
<li>
<Plural value={subscriptionClaimCount} one="# Subscription claim" other="# Subscription claims" />
</li>
)}
{organisationClaimCount > 0 && (
<li>
<Plural value={organisationClaimCount} one="# Organisation claim" other="# Organisation claims" />
</li>
)}
</ul>
</AlertDescription>
</Alert>
)}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)} disabled={isPending}>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
variant="destructive"
loading={isPending}
onClick={async () => deleteTransport({ id: transportId })}
>
<Trans>Delete</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,126 @@
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
const ZSendTestEmailFormSchema = z.object({
to: z.string().email(),
});
type TSendTestEmailFormSchema = z.infer<typeof ZSendTestEmailFormSchema>;
export type EmailTransportSendTestDialogProps = {
transportId: string;
trigger: React.ReactNode;
};
export const EmailTransportSendTestDialog = ({ transportId, trigger }: EmailTransportSendTestDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const [open, setOpen] = useState(false);
const { mutateAsync: sendTest } = trpc.admin.emailTransport.sendTest.useMutation({
onSuccess: () => {
toast({
title: t`Test email sent.`,
});
setOpen(false);
},
onError: (error) => {
toast({
title: t`Test failed.`,
description: error.message,
variant: 'destructive',
});
},
});
const form = useForm<TSendTestEmailFormSchema>({
resolver: zodResolver(ZSendTestEmailFormSchema),
defaultValues: {
to: '',
},
});
const onFormSubmit = async ({ to }: TSendTestEmailFormSchema) => {
await sendTest({ id: transportId, to });
};
useEffect(() => {
if (!open) {
form.reset();
}
}, [open, form]);
return (
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<DialogTrigger asChild onClick={(e) => e.stopPropagation()}>
{trigger}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Send Test Email</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Send a test email using this transport to verify the configuration.</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={form.formState.isSubmitting} className="space-y-4">
<FormField
control={form.control}
name="to"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Email</Trans>
</FormLabel>
<FormControl>
<Input type="email" placeholder={t`test@example.com`} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
variant="secondary"
onClick={() => setOpen(false)}
disabled={form.formState.isSubmitting}
>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Send</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,104 @@
import { trpc } from '@documenso/trpc/react';
import type { TFindEmailTransportsResponse } from '@documenso/trpc/server/admin-router/email-transport/find-email-transports.types';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Trans, useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import {
EmailTransportForm,
type EmailTransportFormValues,
emailTransportFormToConfig,
} from '../forms/email-transport-form';
export type EmailTransportUpdateDialogProps = {
transport: TFindEmailTransportsResponse['data'][number];
trigger: React.ReactNode;
};
export const EmailTransportUpdateDialog = ({ transport, trigger }: EmailTransportUpdateDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const [open, setOpen] = useState(false);
const { mutateAsync: updateTransport, isPending } = trpc.admin.emailTransport.update.useMutation();
const onFormSubmit = async (values: EmailTransportFormValues) => {
try {
await updateTransport({
id: transport.id,
data: {
name: values.name,
fromName: values.fromName,
fromAddress: values.fromAddress,
config: emailTransportFormToConfig(values),
},
});
toast({
title: t`Transport updated.`,
});
setOpen(false);
} catch {
toast({
title: t`Failed to save transport.`,
variant: 'destructive',
});
}
};
return (
<Dialog open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
<DialogTrigger asChild onClick={(e) => e.stopPropagation()}>
{trigger}
</DialogTrigger>
<DialogContent className="scrollbar-hidden max-h-[90vh] overflow-y-auto sm:max-w-md">
<DialogHeader>
<DialogTitle>
<Trans>Edit Email Transport</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Modify the details of the email transport.</Trans>
</DialogDescription>
</DialogHeader>
<EmailTransportForm
isEdit
defaultValues={{
// Pre-fill the non-secret connection settings; secrets stay blank
// and are preserved on save unless re-entered.
...(transport.config ?? {}),
name: transport.name,
fromName: transport.fromName,
fromAddress: transport.fromAddress,
type: transport.type,
}}
onFormSubmit={onFormSubmit}
formSubmitTrigger={
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)} disabled={isPending}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={isPending}>
<Trans>Save changes</Trans>
</Button>
</DialogFooter>
}
/>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,317 @@
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
const ZEmailTransportFormSchema = z.object({
name: z.string().min(1),
fromName: z.string().min(1),
fromAddress: z.string().email(),
type: z.enum(['SMTP_AUTH', 'SMTP_API', 'RESEND', 'MAILCHANNELS']),
host: z.string().optional(),
port: z.coerce.number().int().positive().optional(),
secure: z.boolean().optional(),
ignoreTLS: z.boolean().optional(),
username: z.string().optional(),
password: z.string().optional(),
service: z.string().optional(),
apiKey: z.string().optional(),
apiKeyUser: z.string().optional(),
endpoint: z.string().optional(),
});
export type EmailTransportFormValues = z.infer<typeof ZEmailTransportFormSchema>;
type EmailTransportFormProps = {
defaultValues?: Partial<EmailTransportFormValues>;
isEdit?: boolean;
onFormSubmit: (values: EmailTransportFormValues) => Promise<void>;
formSubmitTrigger?: React.ReactNode;
};
export const EmailTransportForm = ({
defaultValues,
isEdit = false,
onFormSubmit,
formSubmitTrigger,
}: EmailTransportFormProps) => {
const { t } = useLingui();
const form = useForm<EmailTransportFormValues>({
resolver: zodResolver(ZEmailTransportFormSchema),
defaultValues: {
name: '',
fromName: '',
fromAddress: '',
type: 'SMTP_AUTH',
secure: false,
ignoreTLS: false,
...defaultValues,
},
});
const type = form.watch('type');
const secretPlaceholder = isEdit ? t`Leave blank to keep current` : undefined;
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={form.formState.isSubmitting} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input placeholder={t`e.g. Resend (free plans)`} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="fromName"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>From name</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="fromAddress"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>From address</Trans>
</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Transport type</Trans>
</FormLabel>
<Select value={field.value} onValueChange={field.onChange} disabled={isEdit}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="SMTP_AUTH">SMTP (auth)</SelectItem>
<SelectItem value="SMTP_API">SMTP (api)</SelectItem>
<SelectItem value="RESEND">Resend</SelectItem>
<SelectItem value="MAILCHANNELS">MailChannels</SelectItem>
</SelectContent>
</Select>
{isEdit && (
<FormDescription>
<Trans>Transport type cannot be changed after creation.</Trans>
</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
{(type === 'SMTP_AUTH' || type === 'SMTP_API') && (
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="host"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Host</Trans>
</FormLabel>
<FormControl>
<Input {...field} value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="port"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Port</Trans>
</FormLabel>
<FormControl>
<Input type="number" {...field} value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
{type === 'SMTP_AUTH' && (
<>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Username</Trans>
</FormLabel>
<FormControl>
<Input {...field} value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Password</Trans>
</FormLabel>
<FormControl>
<Input type="password" placeholder={secretPlaceholder} {...field} value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{type === 'SMTP_API' && (
<FormField
control={form.control}
name="apiKey"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>API key</Trans>
</FormLabel>
<FormControl>
<Input type="password" placeholder={secretPlaceholder} {...field} value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{(type === 'RESEND' || type === 'MAILCHANNELS') && (
<FormField
control={form.control}
name="apiKey"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>API key</Trans>
</FormLabel>
<FormControl>
<Input type="password" placeholder={secretPlaceholder} {...field} value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{type === 'MAILCHANNELS' && (
<FormField
control={form.control}
name="endpoint"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Endpoint (optional)</Trans>
</FormLabel>
<FormControl>
<Input {...field} value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{formSubmitTrigger}
</fieldset>
</form>
</Form>
);
};
/**
* Maps flat form values to the tRPC `config` discriminated union.
*/
export const emailTransportFormToConfig = (values: EmailTransportFormValues) => {
switch (values.type) {
case 'SMTP_AUTH':
return {
type: 'SMTP_AUTH' as const,
host: values.host ?? '',
port: values.port ?? 587,
secure: values.secure ?? false,
ignoreTLS: values.ignoreTLS ?? false,
username: values.username || undefined,
password: values.password || undefined,
service: values.service || undefined,
};
case 'SMTP_API':
return {
type: 'SMTP_API' as const,
host: values.host ?? '',
port: values.port ?? 587,
secure: values.secure ?? false,
apiKey: values.apiKey || '',
apiKeyUser: values.apiKeyUser || undefined,
};
case 'RESEND':
return { type: 'RESEND' as const, apiKey: values.apiKey || '' };
case 'MAILCHANNELS':
return {
type: 'MAILCHANNELS' as const,
apiKey: values.apiKey || '',
endpoint: values.endpoint || undefined,
};
}
};
@@ -1,5 +1,6 @@
import type { TLicenseClaim } from '@documenso/lib/types/license';
import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription';
import { trpc } from '@documenso/trpc/react';
import { ZCreateSubscriptionClaimRequestSchema } from '@documenso/trpc/server/admin-router/create-subscription-claim.types';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
@@ -13,6 +14,7 @@ import {
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import type { SubscriptionClaim } from '@prisma/client';
@@ -59,9 +61,14 @@ export const SubscriptionClaimForm = ({
emailQuota: subscriptionClaim.emailQuota,
apiRateLimits: subscriptionClaim.apiRateLimits,
apiQuota: subscriptionClaim.apiQuota,
emailTransportId: subscriptionClaim.emailTransportId ?? null,
},
});
const { data: transportsData } = trpc.admin.emailTransport.find.useQuery({ perPage: 100 });
const transports = transportsData?.data ?? [];
const NONE_VALUE = '__none__';
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
@@ -238,6 +245,40 @@ export const SubscriptionClaimForm = ({
<ClaimLimitFields control={form.control} disabled={form.formState.isSubmitting} />
<FormField
control={form.control}
name="emailTransportId"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Email transport</Trans>
</FormLabel>
<Select
value={field.value ?? NONE_VALUE}
onValueChange={(value) => field.onChange(value === NONE_VALUE ? null : value)}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t`Default (system mailer)`} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={NONE_VALUE}>{t`Default (system mailer)`}</SelectItem>
{transports.map((transport) => (
<SelectItem key={transport.id} value={transport.id}>
{transport.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
<Trans>Plans without a transport use the system default mailer.</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{formSubmitTrigger}
</fieldset>
</form>
@@ -0,0 +1,179 @@
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
import { trpc } from '@documenso/trpc/react';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { TableCell } from '@documenso/ui/primitives/table';
import { Trans, useLingui } from '@lingui/react/macro';
import { EditIcon, MoreHorizontalIcon, SendIcon, Trash2Icon } from 'lucide-react';
import { useMemo } from 'react';
import { useSearchParams } from 'react-router';
import { EmailTransportDeleteDialog } from '../dialogs/email-transport-delete-dialog';
import { EmailTransportSendTestDialog } from '../dialogs/email-transport-send-test-dialog';
import { EmailTransportUpdateDialog } from '../dialogs/email-transport-update-dialog';
export const AdminEmailTransportsTable = () => {
const { t, i18n } = useLingui();
const [searchParams] = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
const { data, isLoading, isLoadingError } = trpc.admin.emailTransport.find.useQuery({
query: parsedSearchParams.query,
page: parsedSearchParams.page,
perPage: parsedSearchParams.perPage,
});
const onPaginationChange = (page: number, perPage: number) => {
updateSearchParams({
page,
perPage,
});
};
const results = data ?? {
data: [],
perPage: 20,
currentPage: 1,
totalPages: 1,
};
const columns = useMemo(() => {
return [
{
header: t`Name`,
accessorKey: 'name',
},
{
header: t`Type`,
accessorKey: 'type',
},
{
header: t`From`,
cell: ({ row }) => `${row.original.fromName} <${row.original.fromAddress}>`,
},
{
header: t`Used by claims`,
cell: ({ row }) => row.original._count.subscriptionClaims + row.original._count.organisationClaims,
},
{
header: t`Created`,
accessorKey: 'createdAt',
cell: ({ row }) => i18n.date(row.original.createdAt),
},
{
id: 'actions',
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger>
<MoreHorizontalIcon className="h-5 w-5 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="start" forceMount>
<DropdownMenuLabel>
<Trans>Actions</Trans>
</DropdownMenuLabel>
<EmailTransportUpdateDialog
transport={row.original}
trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<div>
<EditIcon className="mr-2 h-4 w-4" />
<Trans>Edit</Trans>
</div>
</DropdownMenuItem>
}
/>
<EmailTransportSendTestDialog
transportId={row.original.id}
trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<div>
<SendIcon className="mr-2 h-4 w-4" />
<Trans>Send test</Trans>
</div>
</DropdownMenuItem>
}
/>
<EmailTransportDeleteDialog
transportId={row.original.id}
transportName={row.original.name}
subscriptionClaimCount={row.original._count.subscriptionClaims}
organisationClaimCount={row.original._count.organisationClaims}
trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<div>
<Trash2Icon className="mr-2 h-4 w-4" />
<Trans>Delete</Trans>
</div>
</DropdownMenuItem>
}
/>
</DropdownMenuContent>
</DropdownMenu>
),
},
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
}, []);
return (
<div>
<DataTable
columns={columns}
data={results.data}
perPage={results.perPage}
currentPage={results.currentPage}
totalPages={results.totalPages}
onPaginationChange={onPaginationChange}
error={{
enable: isLoadingError,
}}
skeleton={{
enable: isLoading,
rows: 3,
component: (
<>
<TableCell className="py-4 pr-4">
<Skeleton className="h-4 w-24 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-16 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-40 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-20 rounded-full" />
</TableCell>
<TableCell>
<div className="flex flex-row justify-end space-x-2">
<Skeleton className="h-2 w-6 rounded" />
</div>
</TableCell>
</>
),
}}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
</DataTable>
</div>
);
};
@@ -129,6 +129,17 @@ export default function AdminLayout({ loaderData }: Route.ComponentProps) {
</Link>
</Button>
<Button
variant="ghost"
className={cn('justify-start md:w-full', pathname?.startsWith('/admin/email-transports') && 'bg-secondary')}
asChild
>
<Link to="/admin/email-transports">
<MailIcon className="mr-2 h-5 w-5" />
<Trans>Email Transports</Trans>
</Link>
</Button>
<Button
variant="ghost"
className={cn('justify-start md:w-full', pathname?.startsWith('/admin/email-domains') && 'bg-secondary')}
@@ -0,0 +1,59 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { Input } from '@documenso/ui/primitives/input';
import { useLingui } from '@lingui/react/macro';
import { useEffect, useState } from 'react';
import { useLocation, useSearchParams } from 'react-router';
import { EmailTransportCreateDialog } from '~/components/dialogs/email-transport-create-dialog';
import { SettingsHeader } from '~/components/general/settings-header';
import { AdminEmailTransportsTable } from '~/components/tables/admin-email-transports-table';
export default function AdminEmailTransportsPage() {
const { t } = useLingui();
const [searchParams, setSearchParams] = useSearchParams();
const { pathname } = useLocation();
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
/**
* Handle debouncing the search query.
*/
useEffect(() => {
const params = new URLSearchParams(searchParams?.toString());
params.set('query', debouncedSearchQuery);
if (debouncedSearchQuery === '') {
params.delete('query');
}
// If nothing to change then do nothing.
if (params.toString() === searchParams?.toString()) {
return;
}
setSearchParams(params);
}, [debouncedSearchQuery, pathname, searchParams]);
return (
<div>
<SettingsHeader title={t`Email Transports`} subtitle={t`Manage all email transports`} hideDivider>
<EmailTransportCreateDialog />
</SettingsHeader>
<div className="mt-4">
<Input
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t`Search by name or from address`}
className="mb-4"
/>
<AdminEmailTransportsTable />
</div>
</div>
);
}
@@ -24,6 +24,7 @@ import {
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -572,6 +573,10 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
const { mutateAsync: updateOrganisation } = trpc.admin.organisation.update.useMutation();
const { data: transportsData } = trpc.admin.emailTransport.find.useQuery({ perPage: 100 });
const transports = transportsData?.data ?? [];
const NONE_VALUE = '__none__';
const hasRestrictedEnterpriseFeatures = Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).some(
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
(flag) => flag.isEnterprise && !licenseFlags?.[flag.key as keyof TLicenseClaim],
@@ -602,6 +607,7 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
TUpdateOrganisationBillingFormSchema['claims']
>['apiRateLimits'],
apiQuota: organisation.organisationClaim.apiQuota,
emailTransportId: organisation.organisationClaim.emailTransportId ?? null,
},
originalSubscriptionClaimId: organisation.organisationClaim.originalSubscriptionClaimId || '',
},
@@ -865,6 +871,40 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
<ClaimLimitFields control={form.control} prefix="claims." />
<FormField
control={form.control}
name="claims.emailTransportId"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Email transport</Trans>
</FormLabel>
<Select
value={field.value ?? NONE_VALUE}
onValueChange={(value) => field.onChange(value === NONE_VALUE ? null : value)}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t`Default (system mailer)`} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={NONE_VALUE}>{t`Default (system mailer)`}</SelectItem>
{transports.map((transport) => (
<SelectItem key={transport.id} value={transport.id}>
{transport.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
<Trans>Organisations without a transport use the system default mailer.</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Update</Trans>
@@ -0,0 +1,268 @@
import { encryptEmailTransportConfig } from '@documenso/lib/server-only/email/email-transport-config';
import { generateDatabaseId, nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, type Locator, type Page, test } from '@playwright/test';
import { apiSignin } from '../../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
// ─── Cleanup ─────────────────────────────────────────────────────────────────
// Transports seeded by the current test, deleted afterwards. Deleting a transport
// referenced by a claim is safe: the FK is `onDelete: SetNull`.
const transportIdsToCleanup: string[] = [];
test.afterEach(async () => {
if (transportIdsToCleanup.length > 0) {
await prisma.emailTransport.deleteMany({ where: { id: { in: transportIdsToCleanup } } });
transportIdsToCleanup.length = 0;
}
});
// ─── Helpers ─────────────────────────────────────────────────────────────────
const seedTransport = async (label: string) => {
const transport = await prisma.emailTransport.create({
data: {
id: generateDatabaseId('email_transport'),
name: `e2e-transport-${label}-${nanoid()}`,
type: 'RESEND',
fromName: 'Seeded Transport',
fromAddress: 'seeded@example.com',
config: encryptEmailTransportConfig({ type: 'RESEND', apiKey: `re_${nanoid()}` }),
},
});
transportIdsToCleanup.push(transport.id);
return transport;
};
const seedSubscriptionClaim = (name: string) =>
prisma.subscriptionClaim.create({
data: {
name,
teamCount: 1,
memberCount: 1,
envelopeItemCount: 10,
recipientCount: 10,
flags: {},
documentRateLimits: [],
emailRateLimits: [],
apiRateLimits: [],
},
});
/**
* Seeds an organisation whose `OrganisationClaim` is descended (via
* `originalSubscriptionClaimId`) from the supplied subscription claim. This is
* the relationship the backport `updateMany` keys on.
*/
const seedOrgForClaim = async (subscriptionClaimId: string) => {
const { organisation } = await seedUser();
await prisma.organisationClaim.update({
where: { id: organisation.organisationClaim.id },
data: {
originalSubscriptionClaimId: subscriptionClaimId,
emailTransportId: null,
},
});
return organisation;
};
const openClaimUpdateDialog = async (page: Page, claimName: string) => {
// The update dialog lives inside the table row. Wait for the debounced search
// refetch to land BEFORE opening it, otherwise the table re-renders mid-flow
// and unmounts the dialog.
const searchSettled = page
.waitForResponse((r) => r.url().includes('claims.find') && r.url().includes(claimName), { timeout: 15_000 })
.catch(() => undefined);
await page.getByPlaceholder('Search by claim ID or name').fill(claimName);
await searchSettled;
const row = page.getByRole('row', { name: claimName });
await expect(row).toBeVisible();
// The actions dropdown trigger is the last button in the row (the first is the
// ID copy button).
await row.getByRole('button').last().click();
await page.getByRole('menuitem', { name: 'Update' }).click();
const dialog = page.getByRole('dialog');
await expect(dialog.getByRole('heading', { name: 'Update Subscription Claim' })).toBeVisible();
return dialog;
};
/**
* Picks an option from an open Radix Select listbox. The email-transport list is
* populated by a `find` query that can keep re-rendering (it loads up to 100
* transports), so the target option's box may still be shifting — wait for it,
* best-effort scroll it into view, then force the click.
*/
const chooseOption = async (page: Page, name: string) => {
const option = page.getByRole('option', { name });
await option.waitFor({ state: 'visible' });
await option.scrollIntoViewIfNeeded().catch(() => undefined);
await option.click({ force: true });
};
const selectEmailTransport = async (page: Page, dialog: Locator, transportName: string) => {
await dialog.getByRole('combobox').filter({ hasText: 'Default (system mailer)' }).click();
await chooseOption(page, transportName);
};
// ─── Subscription claim: NO backport ─────────────────────────────────────────
test('[ADMIN][EMAIL_TRANSPORT]: updating a subscription claim WITHOUT backport does not touch organisation claims', async ({
page,
}) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const transport = await seedTransport('no-backport');
const claimName = `e2e-claim-no-backport-${nanoid()}`;
const claim = await seedSubscriptionClaim(claimName);
const organisation = await seedOrgForClaim(claim.id);
await apiSignin({ page, email: adminUser.email, redirectPath: '/admin/claims' });
const dialog = await openClaimUpdateDialog(page, claimName);
await selectEmailTransport(page, dialog, transport.name);
// Backport checkbox left UNCHECKED.
await expect(dialog.getByRole('checkbox', { name: 'Backport email transport' })).not.toBeChecked();
await dialog.getByRole('button', { name: 'Update Claim' }).click();
await expect(dialog).not.toBeVisible();
// The subscription claim itself was updated (proves the mutation ran).
await expect
.poll(async () => {
const updated = await prisma.subscriptionClaim.findUniqueOrThrow({ where: { id: claim.id } });
return updated.emailTransportId;
})
.toBe(transport.id);
// The organisation claim was NOT backported.
const orgClaim = await prisma.organisationClaim.findFirstOrThrow({
where: { id: organisation.organisationClaim.id },
});
expect(orgClaim.emailTransportId).toBeNull();
});
// ─── Subscription claim: WITH backport ───────────────────────────────────────
test('[ADMIN][EMAIL_TRANSPORT]: updating a subscription claim WITH backport propagates to organisation claims', async ({
page,
}) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const transport = await seedTransport('backport');
const claimName = `e2e-claim-backport-${nanoid()}`;
const claim = await seedSubscriptionClaim(claimName);
const organisation = await seedOrgForClaim(claim.id);
await apiSignin({ page, email: adminUser.email, redirectPath: '/admin/claims' });
const dialog = await openClaimUpdateDialog(page, claimName);
await selectEmailTransport(page, dialog, transport.name);
// Enable backporting.
const backportCheckbox = dialog.getByRole('checkbox', { name: 'Backport email transport' });
await backportCheckbox.click();
await expect(backportCheckbox).toBeChecked();
await dialog.getByRole('button', { name: 'Update Claim' }).click();
await expect(dialog).not.toBeVisible();
// Both the subscription claim AND the descendant organisation claim are updated.
await expect
.poll(async () => {
const updated = await prisma.subscriptionClaim.findUniqueOrThrow({ where: { id: claim.id } });
return updated.emailTransportId;
})
.toBe(transport.id);
await expect
.poll(async () => {
const orgClaim = await prisma.organisationClaim.findFirstOrThrow({
where: { id: organisation.organisationClaim.id },
});
return orgClaim.emailTransportId;
})
.toBe(transport.id);
});
// ─── Organisation claim transport (set directly on the org page) ─────────────
test('[ADMIN][EMAIL_TRANSPORT]: setting the email transport on an organisation claim persists', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const transport = await seedTransport('org-claim');
const { organisation } = await seedUser();
// Ensure a known starting point.
await prisma.organisationClaim.update({
where: { id: organisation.organisationClaim.id },
data: { emailTransportId: null },
});
await apiSignin({ page, email: adminUser.email, redirectPath: `/admin/organisations/${organisation.id}` });
// Scope to the billing/claims form (the one containing the "Email transport" field);
// the page has a second form (name/url) with its own "Update" button.
const billingForm = page.locator('form', { has: page.getByText('Email transport', { exact: true }) });
await billingForm.getByRole('combobox').filter({ hasText: 'Default (system mailer)' }).click();
await chooseOption(page, transport.name);
await billingForm.getByRole('button', { name: 'Update', exact: true }).click();
await expect
.poll(async () => {
const orgClaim = await prisma.organisationClaim.findFirstOrThrow({
where: { id: organisation.organisationClaim.id },
});
return orgClaim.emailTransportId;
})
.toBe(transport.id);
});
// ─── Organisation claim transport can be reset to the system mailer ──────────
test('[ADMIN][EMAIL_TRANSPORT]: clearing an organisation claim transport resets it to the system mailer', async ({
page,
}) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const transport = await seedTransport('org-clear');
const { organisation } = await seedUser();
// Start with the transport already assigned.
await prisma.organisationClaim.update({
where: { id: organisation.organisationClaim.id },
data: { emailTransportId: transport.id },
});
await apiSignin({ page, email: adminUser.email, redirectPath: `/admin/organisations/${organisation.id}` });
const billingForm = page.locator('form', { has: page.getByText('Email transport', { exact: true }) });
// The select currently shows the transport name; switch back to the default.
await billingForm.getByRole('combobox').filter({ hasText: transport.name }).click();
await chooseOption(page, 'Default (system mailer)');
await billingForm.getByRole('button', { name: 'Update', exact: true }).click();
await expect
.poll(async () => {
const orgClaim = await prisma.organisationClaim.findFirstOrThrow({
where: { id: organisation.organisationClaim.id },
});
return orgClaim.emailTransportId;
})
.toBeNull();
});
@@ -0,0 +1,284 @@
import { decryptEmailTransportConfig } from '@documenso/lib/server-only/email/email-transport-config';
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, type Locator, type Page, test } from '@playwright/test';
import { apiSignin } from '../../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
// ─── Cleanup ─────────────────────────────────────────────────────────────────
// Transport names created by the current test, deleted afterwards so the global
// email-transports table doesn't accumulate rows across runs.
const transportNamesToCleanup: string[] = [];
const trackTransport = (name: string) => {
transportNamesToCleanup.push(name);
return name;
};
test.afterEach(async () => {
if (transportNamesToCleanup.length > 0) {
await prisma.emailTransport.deleteMany({ where: { name: { in: transportNamesToCleanup } } });
transportNamesToCleanup.length = 0;
}
});
// ─── Helpers ─────────────────────────────────────────────────────────────────
const getTransportFromDbOrThrow = async (name: string) => {
await expect
.poll(async () => prisma.emailTransport.findFirst({ where: { name }, select: { id: true } }), {
message: `transport "${name}" was not persisted in time`,
timeout: 10_000,
intervals: [200, 400, 800],
})
.not.toBeNull();
return prisma.emailTransport.findFirstOrThrow({ where: { name } });
};
const openCreateDialog = async (page: Page) => {
await page.getByRole('button', { name: 'Add transport' }).click();
const dialog = page.getByRole('dialog');
await expect(dialog.getByRole('heading', { name: 'Add Email Transport' })).toBeVisible();
return dialog;
};
const selectTransportType = async (page: Page, dialog: Locator, optionName: string) => {
// The transport-type Select is the only combobox inside the create/edit dialog.
await dialog.getByRole('combobox').click();
await page.getByRole('option', { name: optionName, exact: true }).click();
};
const searchForTransport = async (page: Page, name: string) => {
// The row-level Edit/Delete dialogs live inside the table row. Wait for the
// debounced search refetch to land before interacting, otherwise a late
// re-render can unmount a freshly-opened dialog.
const searchSettled = page
.waitForResponse((r) => r.url().includes('emailTransport.find') && r.url().includes(name), { timeout: 15_000 })
.catch(() => undefined);
await page.getByPlaceholder('Search by name or from address').fill(name);
await searchSettled;
await expect(page.getByRole('row', { name })).toBeVisible();
};
const openRowAction = async (page: Page, name: string, action: 'Edit' | 'Send test' | 'Delete') => {
await searchForTransport(page, name);
// The transports table row has exactly one button: the actions dropdown trigger.
await page.getByRole('row', { name }).getByRole('button').click();
await page.getByRole('menuitem', { name: action }).click();
};
// ─── Create: RESEND (round-trips the secret through encrypt/decrypt) ─────────
test('[ADMIN][EMAIL_TRANSPORT]: create a RESEND transport encrypts the secret and round-trips correctly', async ({
page,
}) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const name = trackTransport(`e2e-resend-${nanoid()}`);
const apiKey = `re_${nanoid()}`;
await apiSignin({ page, email: adminUser.email, redirectPath: '/admin/email-transports' });
const dialog = await openCreateDialog(page);
await dialog.getByLabel('Name', { exact: true }).fill(name);
await dialog.getByLabel('From name', { exact: true }).fill('Acme Mailer');
await dialog.getByLabel('From address', { exact: true }).fill('sender@example.com');
await selectTransportType(page, dialog, 'Resend');
await dialog.getByLabel('API key', { exact: true }).fill(apiKey);
await dialog.getByRole('button', { name: 'Create', exact: true }).click();
await expect(dialog).not.toBeVisible();
const row = await getTransportFromDbOrThrow(name);
// The stored blob must NOT contain the plaintext secret.
expect(row.config).not.toContain(apiKey);
expect(row.type).toBe('RESEND');
expect(row.fromName).toBe('Acme Mailer');
expect(row.fromAddress).toBe('sender@example.com');
// Decrypting yields the original config (proves encrypt → store → decrypt works).
const config = decryptEmailTransportConfig(row.config);
expect(config).toEqual({ type: 'RESEND', apiKey });
});
// ─── Create: SMTP_AUTH (non-secret + secret fields) ─────────────────────────
test('[ADMIN][EMAIL_TRANSPORT]: create an SMTP_AUTH transport stores host/port/username and encrypts the password', async ({
page,
}) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const name = trackTransport(`e2e-smtp-${nanoid()}`);
const password = `pw_${nanoid()}`;
await apiSignin({ page, email: adminUser.email, redirectPath: '/admin/email-transports' });
const dialog = await openCreateDialog(page);
await dialog.getByLabel('Name', { exact: true }).fill(name);
await dialog.getByLabel('From name', { exact: true }).fill('SMTP Sender');
await dialog.getByLabel('From address', { exact: true }).fill('smtp-sender@example.com');
// Default type is SMTP_AUTH, so the host/port/username/password fields are already shown.
await dialog.getByLabel('Host', { exact: true }).fill('smtp.example.com');
await dialog.getByLabel('Port', { exact: true }).fill('587');
await dialog.getByLabel('Username', { exact: true }).fill('smtp-user');
await dialog.getByLabel('Password', { exact: true }).fill(password);
await dialog.getByRole('button', { name: 'Create', exact: true }).click();
await expect(dialog).not.toBeVisible();
const row = await getTransportFromDbOrThrow(name);
expect(row.config).not.toContain(password);
const config = decryptEmailTransportConfig(row.config);
expect(config).toEqual({
type: 'SMTP_AUTH',
host: 'smtp.example.com',
port: 587,
secure: false,
ignoreTLS: false,
username: 'smtp-user',
password,
});
});
// ─── Update without a secret preserves the existing secret ───────────────────
test('[ADMIN][EMAIL_TRANSPORT]: updating without a secret keeps the existing secret intact', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const name = trackTransport(`e2e-keep-${nanoid()}`);
const originalApiKey = `re_keep_${nanoid()}`;
await apiSignin({ page, email: adminUser.email, redirectPath: '/admin/email-transports' });
// Create the transport with a secret.
const createDialog = await openCreateDialog(page);
await createDialog.getByLabel('Name', { exact: true }).fill(name);
await createDialog.getByLabel('From name', { exact: true }).fill('Keep Original');
await createDialog.getByLabel('From address', { exact: true }).fill('keep@example.com');
await selectTransportType(page, createDialog, 'Resend');
await createDialog.getByLabel('API key', { exact: true }).fill(originalApiKey);
await createDialog.getByRole('button', { name: 'Create', exact: true }).click();
await expect(createDialog).not.toBeVisible();
await getTransportFromDbOrThrow(name);
// Edit: change a non-secret field, leave the API key blank.
await openRowAction(page, name, 'Edit');
const editDialog = page.getByRole('dialog');
await expect(editDialog.getByRole('heading', { name: 'Edit Email Transport' })).toBeVisible();
// The secret field stays blank (we never re-enter it).
await expect(editDialog.getByLabel('API key', { exact: true })).toHaveValue('');
await editDialog.getByLabel('From name', { exact: true }).fill('Renamed Sender');
await editDialog.getByRole('button', { name: 'Save changes' }).click();
await expect(editDialog).not.toBeVisible();
// The update ran (fromName changed) but the original secret is preserved.
await expect
.poll(async () => {
const row = await prisma.emailTransport.findFirstOrThrow({ where: { name } });
return row.fromName;
})
.toBe('Renamed Sender');
const row = await prisma.emailTransport.findFirstOrThrow({ where: { name } });
const config = decryptEmailTransportConfig(row.config);
expect(config).toEqual({ type: 'RESEND', apiKey: originalApiKey });
});
// ─── Update with a new secret correctly replaces it ──────────────────────────
test('[ADMIN][EMAIL_TRANSPORT]: updating with a new secret replaces the stored secret', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const name = trackTransport(`e2e-replace-${nanoid()}`);
const originalApiKey = `re_old_${nanoid()}`;
const newApiKey = `re_new_${nanoid()}`;
await apiSignin({ page, email: adminUser.email, redirectPath: '/admin/email-transports' });
const createDialog = await openCreateDialog(page);
await createDialog.getByLabel('Name', { exact: true }).fill(name);
await createDialog.getByLabel('From name', { exact: true }).fill('Replace Secret');
await createDialog.getByLabel('From address', { exact: true }).fill('replace@example.com');
await selectTransportType(page, createDialog, 'Resend');
await createDialog.getByLabel('API key', { exact: true }).fill(originalApiKey);
await createDialog.getByRole('button', { name: 'Create', exact: true }).click();
await expect(createDialog).not.toBeVisible();
await getTransportFromDbOrThrow(name);
await openRowAction(page, name, 'Edit');
const editDialog = page.getByRole('dialog');
await editDialog.getByLabel('API key', { exact: true }).fill(newApiKey);
await editDialog.getByRole('button', { name: 'Save changes' }).click();
await expect(editDialog).not.toBeVisible();
await expect
.poll(async () => {
const row = await prisma.emailTransport.findFirstOrThrow({ where: { name } });
const config = decryptEmailTransportConfig(row.config);
return config.type === 'RESEND' ? config.apiKey : null;
})
.toBe(newApiKey);
// And it definitely no longer decrypts to the old secret.
const row = await prisma.emailTransport.findFirstOrThrow({ where: { name } });
expect(row.config).not.toContain(originalApiKey);
});
// ─── Delete ──────────────────────────────────────────────────────────────────
test('[ADMIN][EMAIL_TRANSPORT]: delete removes the transport', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const name = trackTransport(`e2e-delete-${nanoid()}`);
await apiSignin({ page, email: adminUser.email, redirectPath: '/admin/email-transports' });
const createDialog = await openCreateDialog(page);
await createDialog.getByLabel('Name', { exact: true }).fill(name);
await createDialog.getByLabel('From name', { exact: true }).fill('To Delete');
await createDialog.getByLabel('From address', { exact: true }).fill('delete@example.com');
await selectTransportType(page, createDialog, 'Resend');
await createDialog.getByLabel('API key', { exact: true }).fill(`re_${nanoid()}`);
await createDialog.getByRole('button', { name: 'Create', exact: true }).click();
await expect(createDialog).not.toBeVisible();
const row = await getTransportFromDbOrThrow(name);
await openRowAction(page, name, 'Delete');
const deleteDialog = page.getByRole('dialog');
await expect(deleteDialog.getByRole('heading', { name: 'Delete Email Transport' })).toBeVisible();
await deleteDialog.getByRole('button', { name: 'Delete', exact: true }).click();
await expect(deleteDialog).not.toBeVisible();
await expect.poll(async () => prisma.emailTransport.findUnique({ where: { id: row.id } })).toBeNull();
});
// ─── Access control ──────────────────────────────────────────────────────────
test('[ADMIN][EMAIL_TRANSPORT]: a non-admin cannot access the email transports page', async ({ page }) => {
const { user: nonAdminUser } = await seedUser({ isAdmin: false });
await apiSignin({ page, email: nonAdminUser.email, redirectPath: '/admin/email-transports' });
await expect(page.getByRole('button', { name: 'Add transport' })).not.toBeVisible();
});
@@ -75,6 +75,13 @@ export const sendOrganisationAccountLinkConfirmationEmail = async ({
},
});
// We only take `emailLanguage` here and intentionally ignore the resolved
// `emailTransport`/`senderEmail`. Unlike other INTERNAL emails, this is an
// auth-critical SSO account creation/linking confirmation: it must always be
// delivered from trusted Documenso infrastructure (see the `mailer.sendMail`
// below). Routing it through the organisation's own (potentially
// misconfigured) transport could block account linking and lock users out of
// their own SSO setup.
const { emailLanguage } = await getEmailContext({
emailType: 'INTERNAL',
source: {
@@ -101,6 +108,10 @@ export const sendOrganisationAccountLinkConfirmationEmail = async ({
const i18n = await getI18nInstance(emailLanguage);
// Deliberately uses the global Documenso mailer + internal sender (not the
// organisation's configured email transport) so auth/SSO confirmation mail is
// always sent from trusted, controlled infrastructure. See the note on the
// getEmailContext call above.
return mailer.sendMail({
to: {
address: user.email,
@@ -0,0 +1,51 @@
import type { TEmailTransportConfig } from '@documenso/lib/server-only/email/email-transport-config';
import { ResendTransport } from '@documenso/nodemailer-resend';
import type { Transporter } from 'nodemailer';
import { createTransport } from 'nodemailer';
import { MailChannelsTransport } from './mailchannels';
export const buildTransport = (config: TEmailTransportConfig): Transporter => {
switch (config.type) {
case 'MAILCHANNELS':
return createTransport(
MailChannelsTransport.makeTransport({
apiKey: config.apiKey,
endpoint: config.endpoint,
}),
);
case 'RESEND':
return createTransport(
ResendTransport.makeTransport({
apiKey: config.apiKey,
}),
);
case 'SMTP_API':
return createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth: {
user: config.apiKeyUser ?? 'apikey',
pass: config.apiKey,
},
});
case 'SMTP_AUTH':
return createTransport({
host: config.host,
port: config.port,
secure: config.secure,
ignoreTLS: config.ignoreTLS,
auth: config.username
? {
user: config.username,
pass: config.password ?? '',
}
: undefined,
...(config.service ? { service: config.service } : {}),
});
}
};
@@ -1,4 +1,3 @@
import { mailer } from '@documenso/email/mailer';
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
import { isRecipientEmailValidForSending } from '@documenso/lib/utils/recipients';
import { prisma } from '@documenso/prisma';
@@ -48,7 +47,7 @@ export const run = async ({ payload, io }: { payload: TSendDocumentCancelledEmai
},
});
const { branding, emailLanguage, senderEmail, replyToEmail, organisationId, claims, emailsDisabled } =
const { branding, emailLanguage, senderEmail, replyToEmail, organisationId, claims, emailsDisabled, emailTransport } =
await getEmailContext({
emailType: 'RECIPIENT',
source: {
@@ -143,7 +142,7 @@ export const run = async ({ payload, io }: { payload: TSendDocumentCancelledEmai
}),
]);
await mailer.sendMail({
await emailTransport.sendMail({
to: {
name: recipient.name,
address: recipient.email,
@@ -1,4 +1,3 @@
import { mailer } from '@documenso/email/mailer';
import { DocumentCompletedEmailTemplate } from '@documenso/email/templates/document-completed';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
@@ -67,7 +66,7 @@ export const run = async ({ payload, io }: { payload: TSendDocumentCompletedEmai
throw new Error('Document has no recipients');
}
const { branding, emailLanguage, senderEmail, replyToEmail, organisationId, claims, emailsDisabled } =
const { branding, emailLanguage, senderEmail, replyToEmail, organisationId, claims, emailsDisabled, emailTransport } =
await getEmailContext({
emailType: 'RECIPIENT',
source: {
@@ -139,7 +138,7 @@ export const run = async ({ payload, io }: { payload: TSendDocumentCompletedEmai
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
await emailTransport.sendMail({
to: [
{
name: owner.name || '',
@@ -236,7 +235,7 @@ export const run = async ({ payload, io }: { payload: TSendDocumentCompletedEmai
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
await emailTransport.sendMail({
to: [
{
name: recipient.name,
@@ -1,4 +1,3 @@
import { mailer } from '@documenso/email/mailer';
import { DocumentCreatedFromDirectTemplateEmailTemplate } from '@documenso/email/templates/document-created-from-direct-template';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
@@ -51,7 +50,7 @@ export const run = async ({ payload }: { payload: TSendDocumentCreatedFromDirect
const [recipient] = envelope.recipients;
const { user: templateOwner } = envelope;
const { branding, emailLanguage, senderEmail } = await getEmailContext({
const { branding, emailLanguage, senderEmail, emailTransport } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'team',
@@ -79,7 +78,7 @@ export const run = async ({ payload }: { payload: TSendDocumentCreatedFromDirect
renderEmailWithI18N(emailTemplate, { lang: emailLanguage, branding, plainText: true }),
]);
await mailer.sendMail({
await emailTransport.sendMail({
to: [
{
name: templateOwner.name || '',
@@ -1,4 +1,3 @@
import { mailer } from '@documenso/email/mailer';
import OrganisationLimitExceededEmailTemplate from '@documenso/email/templates/organisation-limit-exceeded';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
@@ -51,7 +50,7 @@ export const run = async ({
},
});
const { branding, emailLanguage, senderEmail } = await getEmailContext({
const { branding, emailLanguage, senderEmail, emailTransport } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'organisation',
@@ -86,7 +85,7 @@ export const run = async ({
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
await emailTransport.sendMail({
to: member.user.email,
from: senderEmail,
subject: i18n._(msg`Organisation Review Required`),
@@ -1,4 +1,3 @@
import { mailer } from '@documenso/email/mailer';
import OrganisationJoinEmailTemplate from '@documenso/email/templates/organisation-join';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
@@ -65,7 +64,7 @@ export const run = async ({
},
});
const { branding, emailLanguage, senderEmail } = await getEmailContext({
const { branding, emailLanguage, senderEmail, emailTransport } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'organisation',
@@ -103,7 +102,7 @@ export const run = async ({
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
await emailTransport.sendMail({
to: member.user.email,
from: senderEmail,
subject: i18n._(msg`A new member has joined your organisation`),
@@ -1,4 +1,3 @@
import { mailer } from '@documenso/email/mailer';
import OrganisationLeaveEmailTemplate from '@documenso/email/templates/organisation-leave';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
@@ -60,7 +59,7 @@ export const run = async ({
},
});
const { branding, emailLanguage, senderEmail } = await getEmailContext({
const { branding, emailLanguage, senderEmail, emailTransport } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'organisation',
@@ -97,7 +96,7 @@ export const run = async ({
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
await emailTransport.sendMail({
to: member.user.email,
from: senderEmail,
subject: i18n._(msg`A member has left your organisation`),
@@ -1,4 +1,3 @@
import { mailer } from '@documenso/email/mailer';
import { RecipientExpiredTemplate } from '@documenso/email/templates/recipient-expired';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
@@ -62,7 +61,7 @@ export const run = async ({ payload, io }: { payload: TSendOwnerRecipientExpired
return;
}
const { branding, emailLanguage, senderEmail, emailsDisabled } = await getEmailContext({
const { branding, emailLanguage, senderEmail, emailsDisabled, emailTransport } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
@@ -98,7 +97,7 @@ export const run = async ({ payload, io }: { payload: TSendOwnerRecipientExpired
}),
]);
await mailer.sendMail({
await emailTransport.sendMail({
to: {
name: documentOwner.name || '',
address: documentOwner.email,
@@ -1,4 +1,3 @@
import { mailer } from '@documenso/email/mailer';
import { DocumentRecipientSignedEmailTemplate } from '@documenso/email/templates/document-recipient-signed';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
@@ -75,7 +74,7 @@ export const run = async ({ payload, io }: { payload: TSendRecipientSignedEmailJ
return;
}
const { branding, emailLanguage, senderEmail } = await getEmailContext({
const { branding, emailLanguage, senderEmail, emailTransport } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'team',
@@ -105,7 +104,7 @@ export const run = async ({ payload, io }: { payload: TSendRecipientSignedEmailJ
}),
]);
await mailer.sendMail({
await emailTransport.sendMail({
to: {
name: owner.name ?? '',
address: owner.email,
@@ -64,7 +64,7 @@ export const run = async ({ payload, io }: { payload: TSendSigningRejectionEmail
return;
}
const { branding, emailLanguage, senderEmail, replyToEmail, emailsDisabled } = await getEmailContext({
const { branding, emailLanguage, senderEmail, replyToEmail, emailsDisabled, emailTransport } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
@@ -97,7 +97,7 @@ export const run = async ({ payload, io }: { payload: TSendSigningRejectionEmail
}),
]);
await mailer.sendMail({
await emailTransport.sendMail({
to: {
name: recipient.name,
address: recipient.email,
@@ -1,4 +1,3 @@
import { mailer } from '@documenso/email/mailer';
import DocumentInviteEmailTemplate from '@documenso/email/templates/document-invite';
import { isRecipientEmailValidForSending } from '@documenso/lib/utils/recipients';
import { prisma } from '@documenso/prisma';
@@ -100,6 +99,7 @@ export const run = async ({ payload, io }: { payload: TSendSigningEmailJobDefini
organisationId,
claims,
emailsDisabled,
emailTransport,
} = await getEmailContext({
emailType: 'RECIPIENT',
source: {
@@ -215,7 +215,7 @@ export const run = async ({ payload, io }: { payload: TSendSigningEmailJobDefini
}),
]);
await mailer.sendMail({
await emailTransport.sendMail({
to: {
name: recipient.name,
address: recipient.email,
@@ -52,13 +52,15 @@ export const run = async ({ payload, io }: { payload: TAdminDeleteOrganisationJo
const ownerEmail = organisation.owner.email;
const emailContext = await io.runTask('get-email-context', async () => {
return await getEmailContext({
const { emailTransport: _emailTransport, ...serializableContext } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'organisation',
organisationId: organisation.id,
},
});
return serializableContext;
});
// 1. Orphan all envelopes for every team.
@@ -20,7 +20,7 @@ const BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION_SCHEMA = z.object({
cfr21: z.literal(true).optional(),
hipaa: z.literal(true).optional(),
signingReminders: z.literal(true).optional(),
disableEmails: z.literal(true).optional(),
// Do NOT backport disableEmails.
// Todo: Envelopes - Do we need to check?
// authenticationPortal & emailDomains missing here.
}),
@@ -39,7 +39,7 @@ export const BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION = {
handler: async ({ payload, io }) => {
const handler = await import('./backport-subscription-claims.handler');
await handler.run({ payload, io });
await handler.run({ payload: BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION_SCHEMA.parse(payload), io });
},
} as const satisfies JobDefinition<
typeof BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION_ID,
@@ -1,4 +1,3 @@
import { mailer } from '@documenso/email/mailer';
import { BulkSendCompleteEmail } from '@documenso/email/templates/bulk-send-complete';
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
@@ -164,7 +163,7 @@ export const run = async ({ payload, io }: { payload: TBulkSendTemplateJobDefini
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
});
const { branding, emailLanguage, senderEmail } = await getEmailContext({
const { branding, emailLanguage, senderEmail, emailTransport } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'team',
@@ -186,7 +185,7 @@ export const run = async ({ payload, io }: { payload: TBulkSendTemplateJobDefini
}),
]);
await mailer.sendMail({
await emailTransport.sendMail({
to: {
name: user.name || '',
address: user.email,
@@ -1,4 +1,3 @@
import { mailer } from '@documenso/email/mailer';
import DocumentReminderEmailTemplate from '@documenso/email/templates/document-reminder';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
@@ -111,6 +110,7 @@ export const run = async ({ payload, io }: { payload: TProcessSigningReminderJob
organisationId,
claims,
emailsDisabled,
emailTransport,
} = await getEmailContext({
emailType: 'RECIPIENT',
source: {
@@ -203,7 +203,7 @@ export const run = async ({ payload, io }: { payload: TProcessSigningReminderJob
}),
]);
await mailer.sendMail({
await emailTransport.sendMail({
to: {
name: recipient.name,
address: recipient.email,
@@ -1,4 +1,3 @@
import { mailer } from '@documenso/email/mailer';
import { AccessAuth2FAEmailTemplate } from '@documenso/email/templates/access-auth-2fa';
import { isRecipientEmailValidForSending } from '@documenso/lib/utils/recipients';
import { prisma } from '@documenso/prisma';
@@ -79,7 +78,7 @@ export const send2FATokenEmail = async ({ token, envelopeId }: Send2FATokenEmail
email: recipient.email,
});
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
const { branding, emailLanguage, senderEmail, replyToEmail, emailTransport } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
@@ -108,7 +107,7 @@ export const send2FATokenEmail = async ({ token, envelopeId }: Send2FATokenEmail
// Send email outside any transaction to avoid holding a connection
// open during network I/O.
await mailer.sendMail({
await emailTransport.sendMail({
to: {
address: recipient.email,
name: recipient.name,
@@ -1,4 +1,3 @@
import { mailer } from '@documenso/email/mailer';
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
@@ -45,7 +44,7 @@ export const adminSuperDeleteDocument = async ({ envelopeId, requestMetadata }:
});
}
const { branding, settings, senderEmail, replyToEmail } = await getEmailContext({
const { branding, settings, senderEmail, replyToEmail, emailTransport } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
@@ -89,7 +88,7 @@ export const adminSuperDeleteDocument = async ({ envelopeId, requestMetadata }:
const i18n = await getI18nInstance(lang);
await mailer.sendMail({
await emailTransport.sendMail({
to: {
address: recipient.email,
name: recipient.name,
@@ -1,4 +1,3 @@
import { mailer } from '@documenso/email/mailer';
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
@@ -126,7 +125,7 @@ const handleDocumentOwnerDelete = async ({ envelope, user, requestMetadata }: Ha
return;
}
const { branding, emailLanguage, senderEmail, replyToEmail, emailsDisabled } = await getEmailContext({
const { branding, emailLanguage, senderEmail, replyToEmail, emailsDisabled, emailTransport } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
@@ -224,7 +223,7 @@ const handleDocumentOwnerDelete = async ({ envelope, user, requestMetadata }: Ha
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
await emailTransport.sendMail({
to: {
address: recipient.email,
name: recipient.name,
@@ -1,4 +1,3 @@
import { mailer } from '@documenso/email/mailer';
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
import { resolveExpiresAt } from '@documenso/lib/constants/envelope-expiration';
import { RECIPIENT_ROLE_TO_EMAIL_TYPE, RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
@@ -160,6 +159,7 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
organisationId,
claims,
emailsDisabled,
emailTransport,
} = await getEmailContext({
emailType: 'RECIPIENT',
source: {
@@ -257,7 +257,7 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
// Send email outside any transaction to avoid holding a connection
// open during network I/O.
await mailer.sendMail({
await emailTransport.sendMail({
to: {
address: email,
name,
@@ -1,4 +1,3 @@
import { mailer } from '@documenso/email/mailer';
import { DocumentSuperDeleteEmailTemplate } from '@documenso/email/templates/document-super-delete';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
@@ -46,7 +45,7 @@ export const sendDeleteEmail = async ({ envelopeId, reason }: SendDeleteEmailOpt
return;
}
const { branding, emailLanguage, senderEmail } = await getEmailContext({
const { branding, emailLanguage, senderEmail, emailTransport } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'team',
@@ -76,7 +75,7 @@ export const sendDeleteEmail = async ({ envelopeId, reason }: SendDeleteEmailOpt
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
await emailTransport.sendMail({
to: {
address: email,
name: name || '',
@@ -1,4 +1,3 @@
import { mailer } from '@documenso/email/mailer';
import { DocumentPendingEmailTemplate } from '@documenso/email/templates/document-pending';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
@@ -47,7 +46,7 @@ export const sendPendingEmail = async ({ id, recipientId }: SendPendingEmailOpti
throw new Error('Document has no recipients');
}
const { branding, emailLanguage, senderEmail, replyToEmail, emailsDisabled } = await getEmailContext({
const { branding, emailLanguage, senderEmail, replyToEmail, emailsDisabled, emailTransport } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
@@ -94,7 +93,7 @@ export const sendPendingEmail = async ({ id, recipientId }: SendPendingEmailOpti
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
await emailTransport.sendMail({
to: {
address: email,
name,
@@ -0,0 +1,107 @@
import { DOCUMENSO_ENCRYPTION_SECONDARY_KEY } from '@documenso/lib/constants/crypto';
import { symmetricDecrypt, symmetricEncrypt } from '@documenso/lib/universal/crypto';
import { z } from 'zod';
/**
* Config keys that hold secret values across all transport types.
*
* Secrets are never sent back to the client, so on update an empty incoming
* value means "keep the existing secret". This list lets the update route know
* which fields to preserve when left blank.
*
* Keep in sync with the fields marked `Secret` in the schemas below.
*/
export const EMAIL_TRANSPORT_SECRET_KEYS = ['password', 'apiKey'] as const;
export const ZSmtpAuthConfigSchema = z.object({
type: z.literal('SMTP_AUTH'),
host: z.string().min(1),
port: z.number().int().positive(),
secure: z.boolean().default(false),
ignoreTLS: z.boolean().default(false),
username: z.string().optional(),
password: z.string().optional(), // Secret — keep in sync with EMAIL_TRANSPORT_SECRET_KEYS.
service: z.string().optional(),
});
export const ZSmtpApiConfigSchema = z.object({
type: z.literal('SMTP_API'),
host: z.string().min(1),
port: z.number().int().positive(),
secure: z.boolean().default(false),
apiKey: z.string().min(1), // Secret — keep in sync with EMAIL_TRANSPORT_SECRET_KEYS.
apiKeyUser: z.string().optional(),
});
export const ZResendConfigSchema = z.object({
type: z.literal('RESEND'),
apiKey: z.string().min(1), // Secret — keep in sync with EMAIL_TRANSPORT_SECRET_KEYS.
});
export const ZMailChannelsConfigSchema = z.object({
type: z.literal('MAILCHANNELS'),
apiKey: z.string().min(1), // Secret — keep in sync with EMAIL_TRANSPORT_SECRET_KEYS.
endpoint: z.string().optional(),
});
export const ZEmailTransportConfigSchema = z.discriminatedUnion('type', [
ZSmtpAuthConfigSchema,
ZSmtpApiConfigSchema,
ZResendConfigSchema,
ZMailChannelsConfigSchema,
]);
export type TEmailTransportConfig = z.infer<typeof ZEmailTransportConfigSchema>;
/**
* Non-secret view of a transport config (secret fields removed).
*
* Safe to return to the client so the edit form can pre-fill the connection
* settings without exposing secrets.
*/
export const ZEmailTransportPublicConfigSchema = z.discriminatedUnion('type', [
ZSmtpAuthConfigSchema.omit({ password: true }),
ZSmtpApiConfigSchema.omit({ apiKey: true }),
ZResendConfigSchema.omit({ apiKey: true }),
ZMailChannelsConfigSchema.omit({ apiKey: true }),
]);
export type TEmailTransportPublicConfig = z.infer<typeof ZEmailTransportPublicConfigSchema>;
/**
* Strips secret fields (see EMAIL_TRANSPORT_SECRET_KEYS) from a transport
* config, returning only the non-secret connection settings.
*/
export const toPublicEmailTransportConfig = (config: TEmailTransportConfig): TEmailTransportPublicConfig => {
const publicConfig: Record<string, unknown> = { ...config };
for (const key of EMAIL_TRANSPORT_SECRET_KEYS) {
delete publicConfig[key];
}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return publicConfig as TEmailTransportPublicConfig;
};
export const encryptEmailTransportConfig = (config: TEmailTransportConfig): string => {
if (!DOCUMENSO_ENCRYPTION_SECONDARY_KEY) {
throw new Error('Missing encryption key');
}
return symmetricEncrypt({
key: DOCUMENSO_ENCRYPTION_SECONDARY_KEY,
data: JSON.stringify(config),
});
};
export const decryptEmailTransportConfig = (encrypted: string): TEmailTransportConfig => {
if (!DOCUMENSO_ENCRYPTION_SECONDARY_KEY) {
throw new Error('Missing encryption key');
}
const decrypted = Buffer.from(
symmetricDecrypt({ key: DOCUMENSO_ENCRYPTION_SECONDARY_KEY, data: encrypted }),
).toString('utf-8');
return ZEmailTransportConfigSchema.parse(JSON.parse(decrypted));
};
@@ -1,3 +1,4 @@
import { mailer } from '@documenso/email/mailer';
import type { BrandingSettings } from '@documenso/email/providers/branding';
import { prisma } from '@documenso/prisma';
import type {
@@ -8,15 +9,18 @@ import type {
OrganisationType,
} from '@documenso/prisma/client';
import { EmailDomainStatus, type OrganisationClaim, type OrganisationGlobalSettings } from '@documenso/prisma/client';
import type { Transporter } from 'nodemailer';
import { match, P } from 'ts-pattern';
import { DOCUMENSO_INTERNAL_EMAIL } from '../../constants/email';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { logger } from '../../utils/logger';
import {
organisationGlobalSettingsToBranding,
teamGlobalSettingsToBranding,
} from '../../utils/team-global-settings-to-branding';
import { extractDerivedTeamSettings } from '../../utils/teams';
import { resolveEmailTransport } from './resolve-email-transport';
type EmailMetaOption = Partial<Pick<DocumentMeta, 'emailId' | 'emailReplyTo' | 'language'>>;
@@ -74,6 +78,7 @@ export type EmailContextResponse = {
emailsDisabled: boolean;
organisationId: string;
organisationType: OrganisationType;
emailTransport: Transporter;
senderEmail: {
name: string;
address: string;
@@ -85,7 +90,7 @@ export type EmailContextResponse = {
export const getEmailContext = async (options: GetEmailContextOptions): Promise<EmailContextResponse> => {
const { source, meta } = options;
let emailContext: Omit<EmailContextResponse, 'senderEmail' | 'replyToEmail' | 'emailLanguage'>;
let emailContext: Omit<EmailContextResponse, 'senderEmail' | 'replyToEmail' | 'emailLanguage' | 'emailTransport'>;
if (source.type === 'organisation') {
emailContext = await handleOrganisationEmailContext(source.organisationId);
@@ -95,13 +100,45 @@ export const getEmailContext = async (options: GetEmailContextOptions): Promise<
const emailLanguage = meta?.language || emailContext.settings.documentLanguage;
const transportResolution = emailContext.claims.emailTransportId
? await resolveEmailTransport(emailContext.claims.emailTransportId)
: null;
// A configured transport that fails to resolve is an operational problem, not
// "no transport". Surface it (alertable) before silently falling back to the
// system mailer + Documenso sender, so the degraded organisation is findable.
if (emailContext.claims.emailTransportId && !transportResolution) {
// Todo: Logging
logger.error({
msg: 'Configured email transport could not be resolved; falling back to the system mailer',
emailTransportId: emailContext.claims.emailTransportId,
organisationId: emailContext.organisationId,
});
}
const resolvedTransportData = transportResolution
? {
name: transportResolution.row.fromName,
address: transportResolution.row.fromAddress,
transport: transportResolution.transporter,
}
: {
name: DOCUMENSO_INTERNAL_EMAIL.name,
address: DOCUMENSO_INTERNAL_EMAIL.address,
transport: mailer,
};
// Immediate return for internal emails.
if (options.emailType === 'INTERNAL') {
return {
...emailContext,
senderEmail: DOCUMENSO_INTERNAL_EMAIL,
emailTransport: resolvedTransportData.transport,
senderEmail: {
name: resolvedTransportData.name,
address: resolvedTransportData.address,
},
replyToEmail: undefined,
emailLanguage, // Not sure if we want to use this for internal emails.
emailLanguage,
};
}
@@ -120,16 +157,29 @@ export const getEmailContext = async (options: GetEmailContextOptions): Promise<
emailContext.settings.emailId = null;
}
const senderEmail = foundSenderEmail
? {
// Custom-domain sender (emailDomains): always use the env mailer (SES) and the
// custom sender; the per-plan transport is ignored entirely here.
if (foundSenderEmail) {
return {
...emailContext,
emailTransport: mailer,
senderEmail: {
name: foundSenderEmail.emailName,
address: foundSenderEmail.email,
}
: DOCUMENSO_INTERNAL_EMAIL;
},
replyToEmail,
emailLanguage,
};
}
// No custom-domain sender → per-plan transport (if any) supplies transport + from-address.
return {
...emailContext,
senderEmail,
emailTransport: resolvedTransportData.transport,
senderEmail: {
name: resolvedTransportData.name,
address: resolvedTransportData.address,
},
replyToEmail,
emailLanguage,
};
@@ -0,0 +1,42 @@
import { buildTransport } from '@documenso/email/transports/build-transport';
import { prisma } from '@documenso/prisma';
import type { EmailTransport } from '@documenso/prisma/client';
import type { Transporter } from 'nodemailer';
import { logger } from '../../utils/logger';
import { decryptEmailTransportConfig } from './email-transport-config';
export type ResolvedEmailTransport = {
row: EmailTransport;
transporter: Transporter;
};
/**
* Loads an EmailTransport row, decrypts its config and builds a nodemailer
* Transporter. Returns null when the id does not resolve or the stored config
* cannot be decrypted/built (caller should fall back to the env mailer).
*/
export const resolveEmailTransport = async (emailTransportId: string): Promise<ResolvedEmailTransport | null> => {
const row = await prisma.emailTransport.findUnique({
where: { id: emailTransportId },
});
if (!row) {
return null;
}
try {
const config = decryptEmailTransportConfig(row.config);
const transporter = buildTransport(config);
return { row, transporter };
} catch (err) {
// Todo: Logging
logger.error({
msg: 'Failed to decrypt or build the configured email transport',
err,
emailTransportId,
});
return null;
}
};
@@ -2,7 +2,6 @@ import {
assertMemberCountWithinCap,
syncMemberCountWithStripeSeatPlan,
} from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
import { mailer } from '@documenso/email/mailer';
import { OrganisationInviteEmailTemplate } from '@documenso/email/templates/organisation-invite';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
@@ -187,7 +186,7 @@ export const sendOrganisationMemberInviteEmail = async ({
organisationName: organisation.name,
});
const { branding, emailLanguage, senderEmail, emailsDisabled } = await getEmailContext({
const { branding, emailLanguage, senderEmail, emailsDisabled, emailTransport } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'organisation',
@@ -215,7 +214,7 @@ export const sendOrganisationMemberInviteEmail = async ({
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
await emailTransport.sendMail({
to: email,
from: senderEmail,
subject: i18n._(msg`You have been invited to join ${organisation.name} on Documenso`),
@@ -190,21 +190,23 @@ export const createOrganisationClaimUpsertData = (
subscriptionClaim: Omit<SubscriptionClaim, 'createdAt' | 'updatedAt'>,
) => {
// Done like this to ensure type errors are thrown if items are added.
const data: Omit<Prisma.SubscriptionClaimCreateInput, 'id' | 'createdAt' | 'updatedAt' | 'locked' | 'name'> = {
flags: {
...subscriptionClaim.flags,
},
envelopeItemCount: subscriptionClaim.envelopeItemCount,
recipientCount: subscriptionClaim.recipientCount,
teamCount: subscriptionClaim.teamCount,
memberCount: subscriptionClaim.memberCount,
documentRateLimits: subscriptionClaim.documentRateLimits ?? [],
documentQuota: subscriptionClaim.documentQuota,
emailRateLimits: subscriptionClaim.emailRateLimits ?? [],
emailQuota: subscriptionClaim.emailQuota,
apiRateLimits: subscriptionClaim.apiRateLimits ?? [],
apiQuota: subscriptionClaim.apiQuota,
};
const data: Omit<Prisma.SubscriptionClaimUncheckedCreateInput, 'id' | 'createdAt' | 'updatedAt' | 'locked' | 'name'> =
{
flags: {
...subscriptionClaim.flags,
},
envelopeItemCount: subscriptionClaim.envelopeItemCount,
recipientCount: subscriptionClaim.recipientCount,
teamCount: subscriptionClaim.teamCount,
memberCount: subscriptionClaim.memberCount,
documentRateLimits: subscriptionClaim.documentRateLimits ?? [],
documentQuota: subscriptionClaim.documentQuota,
emailRateLimits: subscriptionClaim.emailRateLimits ?? [],
emailQuota: subscriptionClaim.emailQuota,
apiRateLimits: subscriptionClaim.apiRateLimits ?? [],
apiQuota: subscriptionClaim.apiQuota,
emailTransportId: subscriptionClaim.emailTransportId ?? null,
};
return {
...data,
@@ -5,6 +5,7 @@ import { createElement } from 'react';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { DOCUMENSO_INTERNAL_EMAIL } from '../../constants/email';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import type { EmailContextResponse } from '../email/get-email-context';
@@ -12,7 +13,7 @@ export type SendOrganisationDeleteEmailOptions = {
email: string;
organisationName: string;
deletedByAdmin?: boolean;
emailContext: EmailContextResponse;
emailContext: Omit<EmailContextResponse, 'emailTransport'>;
};
/**
@@ -30,7 +31,7 @@ export const sendOrganisationDeleteEmail = async ({
deletedByAdmin,
});
const { branding, emailLanguage, senderEmail } = emailContext;
const { branding, emailLanguage } = emailContext;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
@@ -39,9 +40,13 @@ export const sendOrganisationDeleteEmail = async ({
const i18n = await getI18nInstance(emailLanguage);
// This is sent through the global Documenso mailer (the org's transport is
// intentionally not used during deletion), so use the Documenso sender to keep
// the From-address aligned with the sending infrastructure (SPF/DKIM). Note the
// org's `senderEmail` on `emailContext` could be a custom transport address.
await mailer.sendMail({
to: email,
from: senderEmail,
from: DOCUMENSO_INTERNAL_EMAIL,
subject: i18n._(msg`Organisation "${organisationName}" has been deleted`),
html,
text,
@@ -1,4 +1,3 @@
import { mailer } from '@documenso/email/mailer';
import RecipientRemovedFromDocumentTemplate from '@documenso/email/templates/recipient-removed-from-document';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
@@ -152,15 +151,23 @@ export const deleteEnvelopeRecipient = async ({
assetBaseUrl,
});
const { branding, emailLanguage, senderEmail, replyToEmail, organisationId, claims, emailsDisabled } =
await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: envelope.teamId,
},
meta: envelope.documentMeta,
});
const {
branding,
emailLanguage,
senderEmail,
replyToEmail,
organisationId,
claims,
emailsDisabled,
emailTransport,
} = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: envelope.teamId,
},
meta: envelope.documentMeta,
});
// Don't send the removal email if the organisation has email sending disabled.
if (emailsDisabled) {
@@ -195,7 +202,7 @@ export const deleteEnvelopeRecipient = async ({
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
await emailTransport.sendMail({
to: {
address: recipientToDelete.email,
name: recipientToDelete.name,
@@ -1,4 +1,3 @@
import { mailer } from '@documenso/email/mailer';
import RecipientRemovedFromDocumentTemplate from '@documenso/email/templates/recipient-removed-from-document';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
@@ -85,7 +84,7 @@ export const setDocumentRecipients = async ({
throw new Error('Document already complete');
}
const { branding, emailLanguage, senderEmail, replyToEmail, organisationId, claims, emailsDisabled } =
const { branding, emailLanguage, senderEmail, replyToEmail, organisationId, claims, emailsDisabled, emailTransport } =
await getEmailContext({
emailType: 'RECIPIENT',
source: {
@@ -326,7 +325,7 @@ export const setDocumentRecipients = async ({
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
await emailTransport.sendMail({
to: {
address: recipient.email,
name: recipient.name,
@@ -1,4 +1,3 @@
import { mailer } from '@documenso/email/mailer';
import { ConfirmTeamEmailTemplate } from '@documenso/email/templates/confirm-team-email';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
@@ -117,7 +116,7 @@ export const sendTeamEmailVerificationEmail = async (email: string, token: strin
token,
});
const { branding, emailLanguage, senderEmail } = await getEmailContext({
const { branding, emailLanguage, senderEmail, emailTransport } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'team',
@@ -136,7 +135,7 @@ export const sendTeamEmailVerificationEmail = async (email: string, token: strin
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
await emailTransport.sendMail({
to: email,
from: senderEmail,
subject: i18n._(msg`A request to use your email has been initiated by ${team.name} on Documenso`),
@@ -1,4 +1,3 @@
import { mailer } from '@documenso/email/mailer';
import { TeamEmailRemovedTemplate } from '@documenso/email/templates/team-email-removed';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
@@ -24,7 +23,7 @@ export type DeleteTeamEmailOptions = {
* The user must either be part of the team with the required permissions, or the owner of the email.
*/
export const deleteTeamEmail = async ({ userId, userEmail, teamId }: DeleteTeamEmailOptions) => {
const { branding, emailLanguage, senderEmail } = await getEmailContext({
const { branding, emailLanguage, senderEmail, emailTransport } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'team',
@@ -87,7 +86,7 @@ export const deleteTeamEmail = async ({ userId, userEmail, teamId }: DeleteTeamE
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
await emailTransport.sendMail({
to: {
address: team.organisation.owner.email,
name: team.organisation.owner.name ?? '',
+2 -3
View File
@@ -1,4 +1,3 @@
import { mailer } from '@documenso/email/mailer';
import { TeamDeleteEmailTemplate } from '@documenso/email/templates/team-delete';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
@@ -120,7 +119,7 @@ export const sendTeamDeleteEmail = async ({ email, team, organisationId }: SendT
teamUrl: team.url,
});
const { branding, emailLanguage, senderEmail } = await getEmailContext({
const { branding, emailLanguage, senderEmail, emailTransport } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'organisation',
@@ -135,7 +134,7 @@ export const sendTeamDeleteEmail = async ({ email, team, organisationId }: SendT
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
await emailTransport.sendMail({
to: email,
from: senderEmail,
subject: i18n._(msg`Team "${team.name}" has been deleted on Documenso`),
+1
View File
@@ -20,6 +20,7 @@ type DatabaseIdPrefix =
| 'envelope'
| 'envelope_item'
| 'email_domain'
| 'email_transport'
| 'org'
| 'org_email'
| 'org_monthly_stat'
@@ -23,5 +23,6 @@ export const generateDefaultSubscriptionClaim = (): Omit<
emailQuota: null,
apiRateLimits: [],
apiQuota: null,
emailTransportId: null,
};
};
@@ -0,0 +1,28 @@
-- CreateEnum
CREATE TYPE "EmailTransportType" AS ENUM ('SMTP_AUTH', 'SMTP_API', 'RESEND', 'MAILCHANNELS');
-- AlterTable
ALTER TABLE "OrganisationClaim" ADD COLUMN "emailTransportId" TEXT;
-- AlterTable
ALTER TABLE "SubscriptionClaim" ADD COLUMN "emailTransportId" TEXT;
-- CreateTable
CREATE TABLE "EmailTransport" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"name" TEXT NOT NULL,
"type" "EmailTransportType" NOT NULL,
"fromName" TEXT NOT NULL,
"fromAddress" TEXT NOT NULL,
"config" TEXT NOT NULL,
CONSTRAINT "EmailTransport_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "SubscriptionClaim" ADD CONSTRAINT "SubscriptionClaim_emailTransportId_fkey" FOREIGN KEY ("emailTransportId") REFERENCES "EmailTransport"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OrganisationClaim" ADD CONSTRAINT "OrganisationClaim_emailTransportId_fkey" FOREIGN KEY ("emailTransportId") REFERENCES "EmailTransport"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+32
View File
@@ -280,6 +280,9 @@ model SubscriptionClaim {
apiRateLimits Json /// [RateLimitArray] @zod.custom.use(ZRateLimitArraySchema)
apiQuota Int?
emailTransportId String?
emailTransport EmailTransport? @relation(fields: [emailTransportId], references: [id], onDelete: SetNull)
}
/// @zod.import(["import { ZClaimFlagsSchema, ZRateLimitArraySchema } from '@documenso/lib/types/subscription';"])
@@ -306,6 +309,9 @@ model OrganisationClaim {
apiRateLimits Json /// [RateLimitArray] @zod.custom.use(ZRateLimitArraySchema)
apiQuota Int?
emailTransportId String?
emailTransport EmailTransport? @relation(fields: [emailTransportId], references: [id], onDelete: SetNull)
}
model OrganisationMonthlyStat {
@@ -1136,6 +1142,32 @@ model OrganisationEmail {
teamGlobalSettings TeamGlobalSettings[]
}
enum EmailTransportType {
SMTP_AUTH
SMTP_API
RESEND
MAILCHANNELS
}
model EmailTransport {
id String @id
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
name String
type EmailTransportType
// Required from-address override (plaintext, non-secret).
fromName String
fromAddress String
// Encrypted JSON blob of the full transport config (secrets + non-secrets).
config String
subscriptionClaims SubscriptionClaim[]
organisationClaims OrganisationClaim[]
}
model OrganisationAuthenticationPortal {
id String @id
organisation Organisation?
@@ -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();