mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 04:12:06 +10:00
fix: add multi email transport system (#2942)
This commit is contained in:
@@ -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,
|
||||
|
||||
+2
-3
@@ -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 || '',
|
||||
|
||||
+2
-3
@@ -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`),
|
||||
|
||||
+2
-3
@@ -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 ?? '',
|
||||
|
||||
@@ -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`),
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user