diff --git a/apps/remix/app/components/dialogs/claim-update-dialog.tsx b/apps/remix/app/components/dialogs/claim-update-dialog.tsx index bcbd91a56..bdcdfbd55 100644 --- a/apps/remix/app/components/dialogs/claim-update-dialog.tsx +++ b/apps/remix/app/components/dialogs/claim-update-dialog.tsx @@ -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={ - - setOpen(false)} disabled={isPending}> - Cancel - + <> + + setBackportEmailTransport(checked === true)} + /> + + Backport email transport + + - - Update Claim - - + + setOpen(false)} disabled={isPending}> + Cancel + + + + Update Claim + + + > } /> diff --git a/apps/remix/app/components/dialogs/email-transport-create-dialog.tsx b/apps/remix/app/components/dialogs/email-transport-create-dialog.tsx new file mode 100644 index 000000000..462e41921 --- /dev/null +++ b/apps/remix/app/components/dialogs/email-transport-create-dialog.tsx @@ -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 ( + !isPending && setOpen(value)}> + e.stopPropagation()} asChild> + {trigger ?? ( + + Add transport + + )} + + + + + + Add Email Transport + + + Fill in the details to create a new email transport. + + + + + setOpen(false)} disabled={isPending}> + Cancel + + + + Create + + + } + /> + + + ); +}; diff --git a/apps/remix/app/components/dialogs/email-transport-delete-dialog.tsx b/apps/remix/app/components/dialogs/email-transport-delete-dialog.tsx new file mode 100644 index 000000000..055d920b3 --- /dev/null +++ b/apps/remix/app/components/dialogs/email-transport-delete-dialog.tsx @@ -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 ( + !isPending && setOpen(value)}> + e.stopPropagation()}> + {trigger} + + + + + + Delete Email Transport + + + Are you sure you want to delete the following transport? + + + + + {transportName} + + + {isInUse && ( + + + Warning, this email transport is currently being used by: + + + {subscriptionClaimCount > 0 && ( + + + + )} + + {organisationClaimCount > 0 && ( + + + + )} + + + + )} + + + setOpen(false)} disabled={isPending}> + Cancel + + + deleteTransport({ id: transportId })} + > + Delete + + + + + ); +}; diff --git a/apps/remix/app/components/dialogs/email-transport-send-test-dialog.tsx b/apps/remix/app/components/dialogs/email-transport-send-test-dialog.tsx new file mode 100644 index 000000000..1a463ff72 --- /dev/null +++ b/apps/remix/app/components/dialogs/email-transport-send-test-dialog.tsx @@ -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; + +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({ + resolver: zodResolver(ZSendTestEmailFormSchema), + defaultValues: { + to: '', + }, + }); + + const onFormSubmit = async ({ to }: TSendTestEmailFormSchema) => { + await sendTest({ id: transportId, to }); + }; + + useEffect(() => { + if (!open) { + form.reset(); + } + }, [open, form]); + + return ( + !form.formState.isSubmitting && setOpen(value)}> + e.stopPropagation()}> + {trigger} + + + + + + Send Test Email + + + Send a test email using this transport to verify the configuration. + + + + + + + ( + + + Email + + + + + + + )} + /> + + + setOpen(false)} + disabled={form.formState.isSubmitting} + > + Cancel + + + + Send + + + + + + + + ); +}; diff --git a/apps/remix/app/components/dialogs/email-transport-update-dialog.tsx b/apps/remix/app/components/dialogs/email-transport-update-dialog.tsx new file mode 100644 index 000000000..5ad3ae023 --- /dev/null +++ b/apps/remix/app/components/dialogs/email-transport-update-dialog.tsx @@ -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 ( + !isPending && setOpen(value)}> + e.stopPropagation()}> + {trigger} + + + + + + Edit Email Transport + + + Modify the details of the email transport. + + + + + setOpen(false)} disabled={isPending}> + Cancel + + + + Save changes + + + } + /> + + + ); +}; diff --git a/apps/remix/app/components/forms/email-transport-form.tsx b/apps/remix/app/components/forms/email-transport-form.tsx new file mode 100644 index 000000000..2e20fdb59 --- /dev/null +++ b/apps/remix/app/components/forms/email-transport-form.tsx @@ -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; + +type EmailTransportFormProps = { + defaultValues?: Partial; + isEdit?: boolean; + onFormSubmit: (values: EmailTransportFormValues) => Promise; + formSubmitTrigger?: React.ReactNode; +}; + +export const EmailTransportForm = ({ + defaultValues, + isEdit = false, + onFormSubmit, + formSubmitTrigger, +}: EmailTransportFormProps) => { + const { t } = useLingui(); + + const form = useForm({ + 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 ( + + + + ( + + + Name + + + + + + + )} + /> + + + ( + + + From name + + + + + + + )} + /> + + ( + + + From address + + + + + + + )} + /> + + + ( + + + Transport type + + + + + + + + + SMTP (auth) + SMTP (api) + Resend + MailChannels + + + {isEdit && ( + + Transport type cannot be changed after creation. + + )} + + + )} + /> + + {(type === 'SMTP_AUTH' || type === 'SMTP_API') && ( + + ( + + + Host + + + + + + + )} + /> + ( + + + Port + + + + + + + )} + /> + + )} + + {type === 'SMTP_AUTH' && ( + <> + ( + + + Username + + + + + + + )} + /> + ( + + + Password + + + + + + + )} + /> + > + )} + + {type === 'SMTP_API' && ( + ( + + + API key + + + + + + + )} + /> + )} + + {(type === 'RESEND' || type === 'MAILCHANNELS') && ( + ( + + + API key + + + + + + + )} + /> + )} + + {type === 'MAILCHANNELS' && ( + ( + + + Endpoint (optional) + + + + + + + )} + /> + )} + + {formSubmitTrigger} + + + + ); +}; + +/** + * 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, + }; + } +}; diff --git a/apps/remix/app/components/forms/subscription-claim-form.tsx b/apps/remix/app/components/forms/subscription-claim-form.tsx index e3cef8d92..1dd903a3f 100644 --- a/apps/remix/app/components/forms/subscription-claim-form.tsx +++ b/apps/remix/app/components/forms/subscription-claim-form.tsx @@ -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 ( @@ -238,6 +245,40 @@ export const SubscriptionClaimForm = ({ + ( + + + Email transport + + field.onChange(value === NONE_VALUE ? null : value)} + > + + + + + + + {t`Default (system mailer)`} + {transports.map((transport) => ( + + {transport.name} + + ))} + + + + Plans without a transport use the system default mailer. + + + + )} + /> + {formSubmitTrigger} diff --git a/apps/remix/app/components/tables/admin-email-transports-table.tsx b/apps/remix/app/components/tables/admin-email-transports-table.tsx new file mode 100644 index 000000000..9b4fff07f --- /dev/null +++ b/apps/remix/app/components/tables/admin-email-transports-table.tsx @@ -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 }) => ( + + + + + + + + Actions + + + e.preventDefault()}> + + + Edit + + + } + /> + + e.preventDefault()}> + + + Send test + + + } + /> + + e.preventDefault()}> + + + Delete + + + } + /> + + + ), + }, + ] satisfies DataTableColumnDef<(typeof results)['data'][number]>[]; + }, []); + + return ( + + + + + + + + + + + + + + + + + + + + + + + > + ), + }} + > + {(table) => } + + + ); +}; diff --git a/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx b/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx index af00d92cb..ef3e23781 100644 --- a/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx +++ b/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx @@ -129,6 +129,17 @@ export default function AdminLayout({ loaderData }: Route.ComponentProps) { + + + + Email Transports + + + 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 ( + + + + + + + setSearchQuery(e.target.value)} + placeholder={t`Search by name or from address`} + className="mb-4" + /> + + + + + ); +} diff --git a/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx b/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx index 9824390d1..ac668ee3f 100644 --- a/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +++ b/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx @@ -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 + ( + + + Email transport + + field.onChange(value === NONE_VALUE ? null : value)} + > + + + + + + + {t`Default (system mailer)`} + {transports.map((transport) => ( + + {transport.name} + + ))} + + + + Organisations without a transport use the system default mailer. + + + + )} + /> + Update diff --git a/packages/app-tests/e2e/admin/email-transports/email-transport-claims.spec.ts b/packages/app-tests/e2e/admin/email-transports/email-transport-claims.spec.ts new file mode 100644 index 000000000..8f543b4b0 --- /dev/null +++ b/packages/app-tests/e2e/admin/email-transports/email-transport-claims.spec.ts @@ -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(); +}); diff --git a/packages/app-tests/e2e/admin/email-transports/email-transport-crud.spec.ts b/packages/app-tests/e2e/admin/email-transports/email-transport-crud.spec.ts new file mode 100644 index 000000000..94df13bb9 --- /dev/null +++ b/packages/app-tests/e2e/admin/email-transports/email-transport-crud.spec.ts @@ -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(); +}); diff --git a/packages/ee/server-only/lib/send-organisation-account-link-confirmation-email.ts b/packages/ee/server-only/lib/send-organisation-account-link-confirmation-email.ts index e0ff03be7..99f25bb4b 100644 --- a/packages/ee/server-only/lib/send-organisation-account-link-confirmation-email.ts +++ b/packages/ee/server-only/lib/send-organisation-account-link-confirmation-email.ts @@ -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, diff --git a/packages/email/transports/build-transport.ts b/packages/email/transports/build-transport.ts new file mode 100644 index 000000000..a16dd4fe8 --- /dev/null +++ b/packages/email/transports/build-transport.ts @@ -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 } : {}), + }); + } +}; diff --git a/packages/lib/jobs/definitions/emails/send-document-cancelled-emails.handler.ts b/packages/lib/jobs/definitions/emails/send-document-cancelled-emails.handler.ts index bca54f651..ef96dbfd6 100644 --- a/packages/lib/jobs/definitions/emails/send-document-cancelled-emails.handler.ts +++ b/packages/lib/jobs/definitions/emails/send-document-cancelled-emails.handler.ts @@ -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, diff --git a/packages/lib/jobs/definitions/emails/send-document-completed-emails.handler.ts b/packages/lib/jobs/definitions/emails/send-document-completed-emails.handler.ts index 79c6d2707..cd0059b05 100644 --- a/packages/lib/jobs/definitions/emails/send-document-completed-emails.handler.ts +++ b/packages/lib/jobs/definitions/emails/send-document-completed-emails.handler.ts @@ -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, diff --git a/packages/lib/jobs/definitions/emails/send-document-created-from-direct-template-email.handler.ts b/packages/lib/jobs/definitions/emails/send-document-created-from-direct-template-email.handler.ts index 4feed8e11..012cce8fd 100644 --- a/packages/lib/jobs/definitions/emails/send-document-created-from-direct-template-email.handler.ts +++ b/packages/lib/jobs/definitions/emails/send-document-created-from-direct-template-email.handler.ts @@ -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 || '', diff --git a/packages/lib/jobs/definitions/emails/send-organisation-limit-exceeded-email.handler.ts b/packages/lib/jobs/definitions/emails/send-organisation-limit-exceeded-email.handler.ts index 365ac8e69..8648e5360 100644 --- a/packages/lib/jobs/definitions/emails/send-organisation-limit-exceeded-email.handler.ts +++ b/packages/lib/jobs/definitions/emails/send-organisation-limit-exceeded-email.handler.ts @@ -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`), diff --git a/packages/lib/jobs/definitions/emails/send-organisation-member-joined-email.handler.ts b/packages/lib/jobs/definitions/emails/send-organisation-member-joined-email.handler.ts index 7de9ea921..ce420366f 100644 --- a/packages/lib/jobs/definitions/emails/send-organisation-member-joined-email.handler.ts +++ b/packages/lib/jobs/definitions/emails/send-organisation-member-joined-email.handler.ts @@ -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`), diff --git a/packages/lib/jobs/definitions/emails/send-organisation-member-left-email.handler.ts b/packages/lib/jobs/definitions/emails/send-organisation-member-left-email.handler.ts index c6a31857a..454feddb7 100644 --- a/packages/lib/jobs/definitions/emails/send-organisation-member-left-email.handler.ts +++ b/packages/lib/jobs/definitions/emails/send-organisation-member-left-email.handler.ts @@ -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`), diff --git a/packages/lib/jobs/definitions/emails/send-owner-recipient-expired-email.handler.ts b/packages/lib/jobs/definitions/emails/send-owner-recipient-expired-email.handler.ts index 847278eb1..985cccbb0 100644 --- a/packages/lib/jobs/definitions/emails/send-owner-recipient-expired-email.handler.ts +++ b/packages/lib/jobs/definitions/emails/send-owner-recipient-expired-email.handler.ts @@ -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, diff --git a/packages/lib/jobs/definitions/emails/send-recipient-signed-email.handler.ts b/packages/lib/jobs/definitions/emails/send-recipient-signed-email.handler.ts index b7afdb6b8..416c210b1 100644 --- a/packages/lib/jobs/definitions/emails/send-recipient-signed-email.handler.ts +++ b/packages/lib/jobs/definitions/emails/send-recipient-signed-email.handler.ts @@ -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, diff --git a/packages/lib/jobs/definitions/emails/send-rejection-emails.handler.ts b/packages/lib/jobs/definitions/emails/send-rejection-emails.handler.ts index ef3b1d5c1..af6b977c5 100644 --- a/packages/lib/jobs/definitions/emails/send-rejection-emails.handler.ts +++ b/packages/lib/jobs/definitions/emails/send-rejection-emails.handler.ts @@ -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, diff --git a/packages/lib/jobs/definitions/emails/send-signing-email.handler.ts b/packages/lib/jobs/definitions/emails/send-signing-email.handler.ts index 9cb34a9ca..819baed98 100644 --- a/packages/lib/jobs/definitions/emails/send-signing-email.handler.ts +++ b/packages/lib/jobs/definitions/emails/send-signing-email.handler.ts @@ -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, diff --git a/packages/lib/jobs/definitions/internal/admin-delete-organisation.handler.ts b/packages/lib/jobs/definitions/internal/admin-delete-organisation.handler.ts index 544861697..3de61331c 100644 --- a/packages/lib/jobs/definitions/internal/admin-delete-organisation.handler.ts +++ b/packages/lib/jobs/definitions/internal/admin-delete-organisation.handler.ts @@ -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. diff --git a/packages/lib/jobs/definitions/internal/backport-subscription-claims.ts b/packages/lib/jobs/definitions/internal/backport-subscription-claims.ts index cbf69c374..ce37bbd4c 100644 --- a/packages/lib/jobs/definitions/internal/backport-subscription-claims.ts +++ b/packages/lib/jobs/definitions/internal/backport-subscription-claims.ts @@ -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, diff --git a/packages/lib/jobs/definitions/internal/bulk-send-template.handler.ts b/packages/lib/jobs/definitions/internal/bulk-send-template.handler.ts index cbaaba414..cb57b42a2 100644 --- a/packages/lib/jobs/definitions/internal/bulk-send-template.handler.ts +++ b/packages/lib/jobs/definitions/internal/bulk-send-template.handler.ts @@ -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, diff --git a/packages/lib/jobs/definitions/internal/process-signing-reminder.handler.ts b/packages/lib/jobs/definitions/internal/process-signing-reminder.handler.ts index 0646c5c3b..7f73e9542 100644 --- a/packages/lib/jobs/definitions/internal/process-signing-reminder.handler.ts +++ b/packages/lib/jobs/definitions/internal/process-signing-reminder.handler.ts @@ -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, diff --git a/packages/lib/server-only/2fa/email/send-2fa-token-email.ts b/packages/lib/server-only/2fa/email/send-2fa-token-email.ts index f7264126a..fb1ec10a1 100644 --- a/packages/lib/server-only/2fa/email/send-2fa-token-email.ts +++ b/packages/lib/server-only/2fa/email/send-2fa-token-email.ts @@ -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, diff --git a/packages/lib/server-only/admin/admin-super-delete-document.ts b/packages/lib/server-only/admin/admin-super-delete-document.ts index a34d88e38..3ae48a0e7 100644 --- a/packages/lib/server-only/admin/admin-super-delete-document.ts +++ b/packages/lib/server-only/admin/admin-super-delete-document.ts @@ -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, diff --git a/packages/lib/server-only/document/delete-document.ts b/packages/lib/server-only/document/delete-document.ts index 8a344c774..3711921c6 100644 --- a/packages/lib/server-only/document/delete-document.ts +++ b/packages/lib/server-only/document/delete-document.ts @@ -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, diff --git a/packages/lib/server-only/document/resend-document.ts b/packages/lib/server-only/document/resend-document.ts index baa060408..050d973c7 100644 --- a/packages/lib/server-only/document/resend-document.ts +++ b/packages/lib/server-only/document/resend-document.ts @@ -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, diff --git a/packages/lib/server-only/document/send-delete-email.ts b/packages/lib/server-only/document/send-delete-email.ts index 81bc1a774..3f198c8f7 100644 --- a/packages/lib/server-only/document/send-delete-email.ts +++ b/packages/lib/server-only/document/send-delete-email.ts @@ -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 || '', diff --git a/packages/lib/server-only/document/send-pending-email.ts b/packages/lib/server-only/document/send-pending-email.ts index df8550186..4d803f5dd 100644 --- a/packages/lib/server-only/document/send-pending-email.ts +++ b/packages/lib/server-only/document/send-pending-email.ts @@ -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, diff --git a/packages/lib/server-only/email/email-transport-config.ts b/packages/lib/server-only/email/email-transport-config.ts new file mode 100644 index 000000000..38fcb164e --- /dev/null +++ b/packages/lib/server-only/email/email-transport-config.ts @@ -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; + +/** + * 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; + +/** + * 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 = { ...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)); +}; diff --git a/packages/lib/server-only/email/get-email-context.ts b/packages/lib/server-only/email/get-email-context.ts index 7d2182b09..348ab9baa 100644 --- a/packages/lib/server-only/email/get-email-context.ts +++ b/packages/lib/server-only/email/get-email-context.ts @@ -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>; @@ -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 => { const { source, meta } = options; - let emailContext: Omit; + let emailContext: Omit; 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, }; diff --git a/packages/lib/server-only/email/resolve-email-transport.ts b/packages/lib/server-only/email/resolve-email-transport.ts new file mode 100644 index 000000000..f83dc318d --- /dev/null +++ b/packages/lib/server-only/email/resolve-email-transport.ts @@ -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 => { + 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; + } +}; diff --git a/packages/lib/server-only/organisation/create-organisation-member-invites.ts b/packages/lib/server-only/organisation/create-organisation-member-invites.ts index 71e7cc73c..5e813e5b0 100644 --- a/packages/lib/server-only/organisation/create-organisation-member-invites.ts +++ b/packages/lib/server-only/organisation/create-organisation-member-invites.ts @@ -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`), diff --git a/packages/lib/server-only/organisation/create-organisation.ts b/packages/lib/server-only/organisation/create-organisation.ts index f77f3e376..8dcd4bd31 100644 --- a/packages/lib/server-only/organisation/create-organisation.ts +++ b/packages/lib/server-only/organisation/create-organisation.ts @@ -190,21 +190,23 @@ export const createOrganisationClaimUpsertData = ( subscriptionClaim: Omit, ) => { // Done like this to ensure type errors are thrown if items are added. - const data: Omit = { - 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 = + { + 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, diff --git a/packages/lib/server-only/organisation/delete-organisation-email.ts b/packages/lib/server-only/organisation/delete-organisation-email.ts index f4cd7baec..b45fd0346 100644 --- a/packages/lib/server-only/organisation/delete-organisation-email.ts +++ b/packages/lib/server-only/organisation/delete-organisation-email.ts @@ -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; }; /** @@ -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, diff --git a/packages/lib/server-only/recipient/delete-envelope-recipient.ts b/packages/lib/server-only/recipient/delete-envelope-recipient.ts index 88aa411bc..4b31770eb 100644 --- a/packages/lib/server-only/recipient/delete-envelope-recipient.ts +++ b/packages/lib/server-only/recipient/delete-envelope-recipient.ts @@ -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, diff --git a/packages/lib/server-only/recipient/set-document-recipients.ts b/packages/lib/server-only/recipient/set-document-recipients.ts index 3f6d4143e..1bf55c6f8 100644 --- a/packages/lib/server-only/recipient/set-document-recipients.ts +++ b/packages/lib/server-only/recipient/set-document-recipients.ts @@ -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, diff --git a/packages/lib/server-only/team/create-team-email-verification.ts b/packages/lib/server-only/team/create-team-email-verification.ts index 483c24792..12db8543b 100644 --- a/packages/lib/server-only/team/create-team-email-verification.ts +++ b/packages/lib/server-only/team/create-team-email-verification.ts @@ -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`), diff --git a/packages/lib/server-only/team/delete-team-email.ts b/packages/lib/server-only/team/delete-team-email.ts index 28aaf1290..0c0374660 100644 --- a/packages/lib/server-only/team/delete-team-email.ts +++ b/packages/lib/server-only/team/delete-team-email.ts @@ -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 ?? '', diff --git a/packages/lib/server-only/team/delete-team.ts b/packages/lib/server-only/team/delete-team.ts index 8c3f1a480..182a004a0 100644 --- a/packages/lib/server-only/team/delete-team.ts +++ b/packages/lib/server-only/team/delete-team.ts @@ -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`), diff --git a/packages/lib/universal/id.ts b/packages/lib/universal/id.ts index 57ec58847..4cee4a5b8 100644 --- a/packages/lib/universal/id.ts +++ b/packages/lib/universal/id.ts @@ -20,6 +20,7 @@ type DatabaseIdPrefix = | 'envelope' | 'envelope_item' | 'email_domain' + | 'email_transport' | 'org' | 'org_email' | 'org_monthly_stat' diff --git a/packages/lib/utils/organisations-claims.ts b/packages/lib/utils/organisations-claims.ts index c6ec2a770..b658defb0 100644 --- a/packages/lib/utils/organisations-claims.ts +++ b/packages/lib/utils/organisations-claims.ts @@ -23,5 +23,6 @@ export const generateDefaultSubscriptionClaim = (): Omit< emailQuota: null, apiRateLimits: [], apiQuota: null, + emailTransportId: null, }; }; diff --git a/packages/prisma/migrations/20260604143030_add_email_transports/migration.sql b/packages/prisma/migrations/20260604143030_add_email_transports/migration.sql new file mode 100644 index 000000000..1089574cf --- /dev/null +++ b/packages/prisma/migrations/20260604143030_add_email_transports/migration.sql @@ -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; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 39e631d51..98cfbcd1d 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -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? diff --git a/packages/trpc/server/admin-router/create-subscription-claim.ts b/packages/trpc/server/admin-router/create-subscription-claim.ts index e921d47e7..44e1e9fc3 100644 --- a/packages/trpc/server/admin-router/create-subscription-claim.ts +++ b/packages/trpc/server/admin-router/create-subscription-claim.ts @@ -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, }, }); }); diff --git a/packages/trpc/server/admin-router/create-subscription-claim.types.ts b/packages/trpc/server/admin-router/create-subscription-claim.types.ts index fd02c0132..f1cfbad60 100644 --- a/packages/trpc/server/admin-router/create-subscription-claim.types.ts +++ b/packages/trpc/server/admin-router/create-subscription-claim.types.ts @@ -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(); diff --git a/packages/trpc/server/admin-router/email-transport/create-email-transport.ts b/packages/trpc/server/admin-router/email-transport/create-email-transport.ts new file mode 100644 index 000000000..e85083e22 --- /dev/null +++ b/packages/trpc/server/admin-router/email-transport/create-email-transport.ts @@ -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, + }; + }); diff --git a/packages/trpc/server/admin-router/email-transport/create-email-transport.types.ts b/packages/trpc/server/admin-router/email-transport/create-email-transport.types.ts new file mode 100644 index 000000000..26a47c12c --- /dev/null +++ b/packages/trpc/server/admin-router/email-transport/create-email-transport.types.ts @@ -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; diff --git a/packages/trpc/server/admin-router/email-transport/delete-email-transport.ts b/packages/trpc/server/admin-router/email-transport/delete-email-transport.ts new file mode 100644 index 000000000..0dbbf9ee7 --- /dev/null +++ b/packages/trpc/server/admin-router/email-transport/delete-email-transport.ts @@ -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, + }, + }); + }); diff --git a/packages/trpc/server/admin-router/email-transport/delete-email-transport.types.ts b/packages/trpc/server/admin-router/email-transport/delete-email-transport.types.ts new file mode 100644 index 000000000..09cc60543 --- /dev/null +++ b/packages/trpc/server/admin-router/email-transport/delete-email-transport.types.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +export const ZDeleteEmailTransportRequestSchema = z.object({ + id: z.string(), +}); + +export const ZDeleteEmailTransportResponseSchema = z.void(); diff --git a/packages/trpc/server/admin-router/email-transport/find-email-transports.ts b/packages/trpc/server/admin-router/email-transport/find-email-transports.ts new file mode 100644 index 000000000..9b372730c --- /dev/null +++ b/packages/trpc/server/admin-router/email-transport/find-email-transports.ts @@ -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 | 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), + }; + }); diff --git a/packages/trpc/server/admin-router/email-transport/find-email-transports.types.ts b/packages/trpc/server/admin-router/email-transport/find-email-transports.types.ts new file mode 100644 index 000000000..d1a4b2d33 --- /dev/null +++ b/packages/trpc/server/admin-router/email-transport/find-email-transports.types.ts @@ -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; +export type TFindEmailTransportsResponse = z.infer; diff --git a/packages/trpc/server/admin-router/email-transport/send-test-email-transport.ts b/packages/trpc/server/admin-router/email-transport/send-test-email-transport.ts new file mode 100644 index 000000000..fd7c53211 --- /dev/null +++ b/packages/trpc/server/admin-router/email-transport/send-test-email-transport.ts @@ -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); + } + }); diff --git a/packages/trpc/server/admin-router/email-transport/send-test-email-transport.types.ts b/packages/trpc/server/admin-router/email-transport/send-test-email-transport.types.ts new file mode 100644 index 000000000..a54eea24d --- /dev/null +++ b/packages/trpc/server/admin-router/email-transport/send-test-email-transport.types.ts @@ -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; diff --git a/packages/trpc/server/admin-router/email-transport/update-email-transport.ts b/packages/trpc/server/admin-router/email-transport/update-email-transport.ts new file mode 100644 index 000000000..9c3383555 --- /dev/null +++ b/packages/trpc/server/admin-router/email-transport/update-email-transport.ts @@ -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 = { ...data.config }; + + if (existingConfig.type === data.config.type) { + for (const key of EMAIL_TRANSPORT_SECRET_KEYS) { + const incoming = (data.config as Record)[key]; + if (incoming === undefined || incoming === '') { + merged[key] = (existingConfig as Record)[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), + }, + }); + }); diff --git a/packages/trpc/server/admin-router/email-transport/update-email-transport.types.ts b/packages/trpc/server/admin-router/email-transport/update-email-transport.types.ts new file mode 100644 index 000000000..d007c7e19 --- /dev/null +++ b/packages/trpc/server/admin-router/email-transport/update-email-transport.types.ts @@ -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; diff --git a/packages/trpc/server/admin-router/find-subscription-claims.types.ts b/packages/trpc/server/admin-router/find-subscription-claims.types.ts index 984fe8ce4..a4230d0d8 100644 --- a/packages/trpc/server/admin-router/find-subscription-claims.types.ts +++ b/packages/trpc/server/admin-router/find-subscription-claims.types.ts @@ -22,6 +22,7 @@ export const ZFindSubscriptionClaimsResponseSchema = ZFindResultResponse.extend( emailQuota: true, apiRateLimits: true, apiQuota: true, + emailTransportId: true, }).array(), }); diff --git a/packages/trpc/server/admin-router/router.ts b/packages/trpc/server/admin-router/router.ts index 5594ce3a7..7aec1968f 100644 --- a/packages/trpc/server/admin-router/router.ts +++ b/packages/trpc/server/admin-router/router.ts @@ -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, }, diff --git a/packages/trpc/server/admin-router/update-admin-organisation.types.ts b/packages/trpc/server/admin-router/update-admin-organisation.types.ts index 9645d8c36..5f5d4a17a 100644 --- a/packages/trpc/server/admin-router/update-admin-organisation.types.ts +++ b/packages/trpc/server/admin-router/update-admin-organisation.types.ts @@ -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(), diff --git a/packages/trpc/server/admin-router/update-subscription-claim.ts b/packages/trpc/server/admin-router/update-subscription-claim.ts index 5630e04cf..2f9fde912 100644 --- a/packages/trpc/server/admin-router/update-subscription-claim.ts +++ b/packages/trpc/server/admin-router/update-subscription-claim.ts @@ -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', diff --git a/packages/trpc/server/admin-router/update-subscription-claim.types.ts b/packages/trpc/server/admin-router/update-subscription-claim.types.ts index b9ea7b504..d0fd4ccfe 100644 --- a/packages/trpc/server/admin-router/update-subscription-claim.types.ts +++ b/packages/trpc/server/admin-router/update-subscription-claim.types.ts @@ -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();