Merge remote-tracking branch 'origin/main' into pr-2559

# Conflicts:
#	packages/lib/translations/de/web.po
#	packages/lib/translations/en/web.po
#	packages/lib/translations/es/web.po
#	packages/lib/translations/fr/web.po
#	packages/lib/translations/it/web.po
#	packages/lib/translations/ja/web.po
#	packages/lib/translations/ko/web.po
#	packages/lib/translations/nl/web.po
#	packages/lib/translations/pl/web.po
#	packages/lib/translations/pt-BR/web.po
#	packages/lib/translations/zh/web.po
This commit is contained in:
ephraimduncan
2026-05-14 15:44:00 +00:00
88 changed files with 7290 additions and 1741 deletions
@@ -0,0 +1,90 @@
import { env } from '@documenso/lib/utils/env';
export const DOCUMENT_CONVERSION_MIME_TYPE_DOCX =
'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
const DEFAULT_DOCUMENT_CONVERSION_TIMEOUT_MS = 30_000;
/**
* Returns whether the document conversion feature is enabled.
*
* Platform-aware:
* - On the server, checks the private URL is configured.
* - On the client, reads the derived public flag injected via `window.__ENV__`.
*/
export const IS_DOCUMENT_CONVERSION_ENABLED = (): boolean => {
if (typeof window === 'undefined') {
return !!env('NEXT_PRIVATE_DOCUMENT_CONVERSION_URL');
}
return env('NEXT_PUBLIC_DOCUMENT_CONVERSION_ENABLED') === 'true';
};
/**
* Returns the configured conversion service base URL as supplied via env, or
* `undefined` if not configured.
*
* Server-side only.
*/
export const DOCUMENT_CONVERSION_URL = (): string | undefined => {
return env('NEXT_PRIVATE_DOCUMENT_CONVERSION_URL');
};
/**
* Returns HTTP Basic auth credentials for the conversion service, or
* `undefined` if either env var is missing. When Gotenberg is started with
* `--api-enable-basic-auth`, every request must carry these credentials.
*
* Server-side only.
*/
export const DOCUMENT_CONVERSION_AUTH = (): { username: string; password: string } | undefined => {
const username = env('NEXT_PRIVATE_DOCUMENT_CONVERSION_USERNAME');
const password = env('NEXT_PRIVATE_DOCUMENT_CONVERSION_PASSWORD');
if (!username || !password) {
return undefined;
}
return { username, password };
};
/**
* Returns the per-request timeout for conversion calls in milliseconds.
*
* Falls back to a 30 second default when the env value is missing or
* unparseable.
*/
export const DOCUMENT_CONVERSION_TIMEOUT_MS = (): number => {
const raw = env('NEXT_PRIVATE_DOCUMENT_CONVERSION_TIMEOUT_MS');
if (!raw) {
return DEFAULT_DOCUMENT_CONVERSION_TIMEOUT_MS;
}
const parsed = parseInt(raw, 10);
if (Number.isNaN(parsed) || parsed <= 0) {
return DEFAULT_DOCUMENT_CONVERSION_TIMEOUT_MS;
}
return parsed;
};
/**
* Returns the mime type -> extensions map that should be passed to the
* dropzone `accept` config and used for server-side validation.
*
* Always includes PDF; only includes DOCX when the conversion feature is
* enabled.
*/
export const getAllowedUploadMimeTypes = (): Record<string, string[]> => {
const base: Record<string, string[]> = {
'application/pdf': ['.pdf'],
};
if (IS_DOCUMENT_CONVERSION_ENABLED()) {
base[DOCUMENT_CONVERSION_MIME_TYPE_DOCX] = ['.docx'];
}
return base;
};
+4
View File
@@ -10,8 +10,10 @@ import { SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION } from './definitions/emails
import { SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION } from './definitions/emails/send-rejection-emails';
import { SEND_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-signing-email';
import { SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-deleted-email';
import { ADMIN_DELETE_ORGANISATION_JOB_DEFINITION } from './definitions/internal/admin-delete-organisation';
import { BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION } from './definitions/internal/backport-subscription-claims';
import { BULK_SEND_TEMPLATE_JOB_DEFINITION } from './definitions/internal/bulk-send-template';
import { CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION } from './definitions/internal/cancel-organisation-subscription';
import { CLEANUP_RATE_LIMITS_JOB_DEFINITION } from './definitions/internal/cleanup-rate-limits';
import { EXECUTE_WEBHOOK_JOB_DEFINITION } from './definitions/internal/execute-webhook';
import { EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION } from './definitions/internal/expire-recipients-sweep';
@@ -49,6 +51,8 @@ export const jobsClient = new JobClient([
PROCESS_SIGNING_REMINDER_JOB_DEFINITION,
CLEANUP_RATE_LIMITS_JOB_DEFINITION,
SYNC_EMAIL_DOMAINS_JOB_DEFINITION,
ADMIN_DELETE_ORGANISATION_JOB_DEFINITION,
CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION,
] as const);
export const jobs = jobsClient;
@@ -0,0 +1,113 @@
import { prisma } from '@documenso/prisma';
import { ORGANISATION_USER_ACCOUNT_TYPE } from '../../../constants/organisations';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { orphanEnvelopes } from '../../../server-only/envelope/orphan-envelopes';
import { sendOrganisationDeleteEmail } from '../../../server-only/organisation/delete-organisation-email';
import { jobs } from '../../client';
import type { JobRunIO } from '../../client/_internal/job';
import type { TAdminDeleteOrganisationJobDefinition } from './admin-delete-organisation';
export const run = async ({ payload, io }: { payload: TAdminDeleteOrganisationJobDefinition; io: JobRunIO }) => {
const { organisationId, sendEmailToOwner, requestedByUserId } = payload;
// Get/store the organisation in a task so it can be accessed by subsequent tasks.
const organisation = await io.runTask('get-organisation', async () => {
io.logger.info(`User ${requestedByUserId} is deleting organisation ${organisationId}`);
return await prisma.organisation.findUnique({
where: {
id: organisationId,
},
select: {
id: true,
name: true,
owner: {
select: {
id: true,
email: true,
name: true,
},
},
teams: {
select: {
id: true,
},
},
subscription: {
select: {
planId: true,
},
},
},
});
});
if (!organisation) {
// The organisation may have already been deleted by a prior run / another
// pathway. Treat as a no-op so the job doesn't retry forever.
return;
}
const ownerEmail = organisation.owner.email;
const emailContext = await io.runTask('get-email-context', async () => {
return await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'organisation',
organisationId: organisation.id,
},
});
});
// 1. Orphan all envelopes for every team.
for (const team of organisation.teams) {
await io.runTask(`orphan-envelopes--team-${team.id}`, async () => {
await orphanEnvelopes({ teamId: team.id });
});
}
// 2. Delete the organisation. Matches the transaction in organisation-router/delete-organisation.ts.
await io.runTask('delete-organisation', async () => {
await prisma.$transaction(async (tx) => {
await tx.account.deleteMany({
where: {
type: ORGANISATION_USER_ACCOUNT_TYPE,
provider: organisation.id,
},
});
await tx.organisation.delete({
where: {
id: organisation.id,
},
});
});
});
// 3. Send the owner notification.
if (sendEmailToOwner) {
await io.runTask('send-organisation-deleted-email', async () => {
await sendOrganisationDeleteEmail({
email: ownerEmail,
organisationName: organisation.name,
deletedByAdmin: true,
emailContext,
});
});
}
// 4. If the organisation has a Stripe subscription, schedule it to be cancelled at the end of the current billing period.
if (organisation.subscription) {
const stripeSubscriptionId = organisation.subscription.planId;
await jobs.triggerJob({
name: 'internal.cancel-organisation-subscription',
payload: {
stripeSubscriptionId,
organisationId: organisation.id,
},
});
}
};
@@ -0,0 +1,37 @@
import { z } from 'zod';
import type { JobDefinition } from '../../client/_internal/job';
const ADMIN_DELETE_ORGANISATION_JOB_DEFINITION_ID = 'internal.admin-delete-organisation';
const ADMIN_DELETE_ORGANISATION_JOB_DEFINITION_SCHEMA = z.object({
organisationId: z.string(),
/**
* Whether to email the organisation owner notifying them of the deletion.
*/
sendEmailToOwner: z.boolean(),
/**
* The id of the admin user who requested the deletion (for audit/logging).
*/
requestedByUserId: z.number(),
});
export type TAdminDeleteOrganisationJobDefinition = z.infer<typeof ADMIN_DELETE_ORGANISATION_JOB_DEFINITION_SCHEMA>;
export const ADMIN_DELETE_ORGANISATION_JOB_DEFINITION = {
id: ADMIN_DELETE_ORGANISATION_JOB_DEFINITION_ID,
name: 'Admin Delete Organisation',
version: '1.0.0',
trigger: {
name: ADMIN_DELETE_ORGANISATION_JOB_DEFINITION_ID,
schema: ADMIN_DELETE_ORGANISATION_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./admin-delete-organisation.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof ADMIN_DELETE_ORGANISATION_JOB_DEFINITION_ID,
TAdminDeleteOrganisationJobDefinition
>;
@@ -0,0 +1,38 @@
import { Stripe, stripe } from '../../../server-only/stripe';
import type { JobRunIO } from '../../client/_internal/job';
import type { TCancelOrganisationSubscriptionJobDefinition } from './cancel-organisation-subscription';
/**
* Marks the given Stripe subscription for cancellation at the end of the
* current billing period.
*
* Idempotent: calling this on an already-cancel-at-period-end subscription is
* a no-op for Stripe and returns the same shape, so re-running the job after
* a partial failure is safe.
*
* If the subscription no longer exists in Stripe (`resource_missing`), the
* job treats it as a no-op rather than retrying forever \u2014 nothing further
* can be done.
*/
export const run = async ({ payload }: { payload: TCancelOrganisationSubscriptionJobDefinition; io: JobRunIO }) => {
const { stripeSubscriptionId } = payload;
try {
await stripe.subscriptions.update(stripeSubscriptionId, {
cancel_at_period_end: true,
});
} catch (error) {
// Subscription no longer exists in Stripe \u2014 nothing to cancel. Treat as
// success so the job doesn't retry indefinitely.
if (error instanceof Stripe.errors.StripeInvalidRequestError && error.code === 'resource_missing') {
console.warn(
`[CANCEL_ORGANISATION_SUBSCRIPTION] Stripe subscription ${stripeSubscriptionId} no longer exists; skipping.`,
);
return;
}
// Anything else: rethrow so the job runner retries.
throw error;
}
};
@@ -0,0 +1,43 @@
import { z } from 'zod';
import type { JobDefinition } from '../../client/_internal/job';
const CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION_ID = 'internal.cancel-organisation-subscription';
const CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION_SCHEMA = z.object({
/**
* The Stripe subscription id (Subscription.planId in our schema).
*
* This must be captured before the local organisation row is deleted,
* because the Subscription row cascades away when the organisation is
* removed.
*/
stripeSubscriptionId: z.string(),
/**
* The organisation id, for logging only. The organisation may no longer
* exist by the time this job runs.
*/
organisationId: z.string(),
});
export type TCancelOrganisationSubscriptionJobDefinition = z.infer<
typeof CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION_SCHEMA
>;
export const CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION = {
id: CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION_ID,
name: 'Cancel Organisation Subscription',
version: '1.0.0',
trigger: {
name: CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION_ID,
schema: CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./cancel-organisation-subscription.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION_ID,
TCancelOrganisationSubscriptionJobDefinition
>;
@@ -0,0 +1,37 @@
/**
* In-process circuit breaker for the document conversion service.
*
* Behaviour: any failure opens the circuit for `COOLDOWN_MS`. While open,
* callers should fail fast without hitting the network. The first request
* after the cooldown is allowed through and either closes the circuit (on
* success) or re-opens it for another cooldown window (on failure).
*
* State is stored on `globalThis` so it survives Vite/Remix HMR in dev and
* is unambiguously process-wide. This module is intentionally pure and
* synchronous: no I/O, no logger import — callers handle observability.
*/
const COOLDOWN_MS = 30_000;
declare global {
// eslint-disable-next-line no-var
var __documensoConversionCircuitOpenedAt: number | null | undefined;
}
export const isCircuitOpen = (): boolean => {
const openedAt = globalThis.__documensoConversionCircuitOpenedAt;
if (!openedAt) {
return false;
}
return Date.now() - openedAt < COOLDOWN_MS;
};
export const recordSuccess = (): void => {
globalThis.__documensoConversionCircuitOpenedAt = null;
};
export const recordFailure = (): void => {
globalThis.__documensoConversionCircuitOpenedAt = Date.now();
};
@@ -0,0 +1,92 @@
import { AppError } from '@documenso/lib/errors/app-error';
import type { Logger } from 'pino';
import {
DOCUMENT_CONVERSION_MIME_TYPE_DOCX,
IS_DOCUMENT_CONVERSION_ENABLED,
} from '../../constants/document-conversion';
import { isCircuitOpen, recordFailure, recordSuccess } from './circuit-breaker';
import { convertDocxToPdfViaGotenberg } from './gotenberg';
type ConvertDocxToPdfOptions = {
buffer: Buffer;
filename: string;
};
const NOT_CONFIGURED_USER_MESSAGE = "Document conversion isn't enabled on this instance. Please upload a PDF.";
const UNAVAILABLE_USER_MESSAGE =
'Document conversion is temporarily unavailable. Please try again shortly or upload a PDF.';
/**
* Converts a DOCX buffer to a PDF buffer via the configured Gotenberg
* conversion service. Guards on feature-enabled and circuit-open state,
* and emits a structured log line for each attempt.
*/
export const convertDocxToPdf = async (
{ buffer, filename }: ConvertDocxToPdfOptions,
logger?: Logger,
): Promise<Buffer> => {
if (!IS_DOCUMENT_CONVERSION_ENABLED()) {
throw new AppError('CONVERSION_SERVICE_UNAVAILABLE', {
message: 'Conversion service not configured',
userMessage: NOT_CONFIGURED_USER_MESSAGE,
statusCode: 503,
});
}
if (isCircuitOpen()) {
throw new AppError('CONVERSION_SERVICE_UNAVAILABLE', {
message: 'Conversion circuit is open; failing fast',
userMessage: UNAVAILABLE_USER_MESSAGE,
statusCode: 503,
});
}
const startedAt = Date.now();
try {
const outputBuffer = await convertDocxToPdfViaGotenberg({ buffer, filename });
recordSuccess();
logger?.info({
event: 'document_conversion_attempt',
filename,
sourceMimeType: DOCUMENT_CONVERSION_MIME_TYPE_DOCX,
durationMs: Date.now() - startedAt,
inputBytes: buffer.byteLength,
outputBytes: outputBuffer.byteLength,
});
return outputBuffer;
} catch (err) {
recordFailure();
const errMessage = err instanceof Error ? err.message : String(err);
const errCode = err instanceof AppError ? err.code : 'UNKNOWN';
const logData = {
event: 'document_conversion_attempt',
filename,
sourceMimeType: DOCUMENT_CONVERSION_MIME_TYPE_DOCX,
durationMs: Date.now() - startedAt,
inputBytes: buffer.byteLength,
failed: true,
errorCode: errCode,
error: errMessage,
};
// A non-2xx from the conversion service surfaces as CONVERSION_FAILED.
// We log those at `error` level (status + truncated body live in the
// AppError message). All other failures stay at `info` to avoid noisy
// logs from transient network blips that the breaker already handles.
if (errCode === 'CONVERSION_FAILED') {
logger?.error(logData);
} else {
logger?.info(logData);
}
throw err;
}
};
@@ -0,0 +1,135 @@
import { AppError } from '@documenso/lib/errors/app-error';
import {
DOCUMENT_CONVERSION_AUTH,
DOCUMENT_CONVERSION_MIME_TYPE_DOCX,
DOCUMENT_CONVERSION_TIMEOUT_MS,
DOCUMENT_CONVERSION_URL,
} from '../../constants/document-conversion';
type ConvertDocxToPdfViaGotenbergOptions = {
buffer: Buffer;
filename: string;
};
const UNAVAILABLE_USER_MESSAGE =
'Document conversion is temporarily unavailable. Please try again shortly or upload a PDF.';
const NOT_CONFIGURED_USER_MESSAGE = "Document conversion isn't enabled on this instance. Please upload a PDF.";
const CONVERSION_FAILED_USER_MESSAGE =
"We couldn't convert this file. Please check it's a valid Word document or upload a PDF instead.";
const MAX_ERROR_BODY_CHARS = 500;
/**
* Posts a DOCX file to the configured Gotenberg-compatible conversion
* service and returns the resulting PDF bytes.
*
* Throws an `AppError` for all failure modes:
* - `CONVERSION_SERVICE_UNAVAILABLE` for missing config, timeout, or
* network errors.
* - `CONVERSION_FAILED` for non-2xx responses from the service.
*/
export const convertDocxToPdfViaGotenberg = async ({
buffer,
filename,
}: ConvertDocxToPdfViaGotenbergOptions): Promise<Buffer> => {
const url = DOCUMENT_CONVERSION_URL();
if (!url) {
throw new AppError('CONVERSION_SERVICE_UNAVAILABLE', {
message: 'Conversion service URL is not configured',
userMessage: NOT_CONFIGURED_USER_MESSAGE,
statusCode: 503,
});
}
const formData = new FormData();
const blob = new Blob([buffer], { type: DOCUMENT_CONVERSION_MIME_TYPE_DOCX });
formData.append('files', blob, filename);
// Tell LibreOffice NOT to export Word content controls (`<w:sdt>`) as PDF
// AcroForm fields. By default Gotenberg renders the field values into form
// appearance streams that reference unembedded base fonts (Times-Roman,
// Times-Bold). Our downstream `normalizePdf` flattens the form, but the
// pdf-lib flattening drops those appearance streams, so every SDT-bound
// string (i.e. virtually all of the body text in Office resume / cover-
// letter templates) ends up invisible in the final PDF. Disabling form
// export makes LibreOffice render those strings as regular text in the
// page content stream, with all glyphs embedded.
formData.append('exportFormFields', 'false');
// When the service is launched with `--api-enable-basic-auth`, every
// route (including `/health` and `/forms/libreoffice/convert`) requires
// HTTP Basic credentials. When auth env vars are not configured we send
// no header and rely on the service running without auth enabled.
const auth = DOCUMENT_CONVERSION_AUTH();
const headers: Record<string, string> = {};
if (auth) {
const encoded = Buffer.from(`${auth.username}:${auth.password}`).toString('base64');
headers.Authorization = `Basic ${encoded}`;
}
const controller = new AbortController();
const timeoutHandle = setTimeout(() => controller.abort(), DOCUMENT_CONVERSION_TIMEOUT_MS());
const convertEndpoint = new URL('/forms/libreoffice/convert', url).toString();
try {
const response = await fetch(convertEndpoint, {
method: 'POST',
body: formData,
headers,
signal: controller.signal,
});
if (!response.ok) {
let body = '';
try {
body = await response.text();
} catch {
body = '';
}
const truncatedBody = body.length > MAX_ERROR_BODY_CHARS ? `${body.slice(0, MAX_ERROR_BODY_CHARS)}...` : body;
throw new AppError('CONVERSION_FAILED', {
message: `Conversion service returned ${response.status}: ${truncatedBody}`,
userMessage: CONVERSION_FAILED_USER_MESSAGE,
statusCode: 400,
});
}
const arrayBuffer = await response.arrayBuffer();
return Buffer.from(arrayBuffer);
} catch (err) {
if (err instanceof AppError) {
throw err;
}
const isAbortError = err instanceof Error && err.name === 'AbortError';
if (isAbortError) {
throw new AppError('CONVERSION_SERVICE_UNAVAILABLE', {
message: 'Conversion service timed out',
userMessage: UNAVAILABLE_USER_MESSAGE,
statusCode: 503,
});
}
const errMessage = err instanceof Error ? err.message : String(err);
throw new AppError('CONVERSION_SERVICE_UNAVAILABLE', {
message: `Conversion service request failed: ${errMessage}`,
userMessage: UNAVAILABLE_USER_MESSAGE,
statusCode: 503,
});
} finally {
clearTimeout(timeoutHandle);
}
};
@@ -0,0 +1,43 @@
import { AppError } from '@documenso/lib/errors/app-error';
import type { Logger } from 'pino';
import { DOCUMENT_CONVERSION_MIME_TYPE_DOCX } from '../../constants/document-conversion';
import { convertDocxToPdf } from './docx-to-pdf';
// We should work on unifying these later on.
type FileInput = {
name: string;
type: string;
arrayBuffer: () => Promise<ArrayBuffer>;
};
const UNSUPPORTED_USER_MESSAGE = "This file type isn't supported. Please upload a PDF or Word document.";
/**
* Entry point for upload routes. Returns a PDF buffer for any supported
* input file:
*
* - PDF in → PDF out (no conversion, no network call).
* - DOCX in → converted PDF out via the configured conversion service.
* - Any other mime type → throws `UNSUPPORTED_FILE_TYPE`.
*
* To support new source formats (PowerPoint, HTML, ...), add a new
* `<format>-to-pdf.ts` sibling and dispatch to it from here.
*/
export const convertToPdf = async (file: FileInput, logger?: Logger): Promise<Buffer> => {
if (file.type === 'application/pdf') {
return Buffer.from(await file.arrayBuffer());
}
if (file.type === DOCUMENT_CONVERSION_MIME_TYPE_DOCX) {
const buffer = Buffer.from(await file.arrayBuffer());
return convertDocxToPdf({ buffer, filename: file.name }, logger);
}
throw new AppError('UNSUPPORTED_FILE_TYPE', {
message: `Unsupported file type: ${file.type}`,
userMessage: UNSUPPORTED_USER_MESSAGE,
statusCode: 400,
});
};
@@ -61,7 +61,7 @@ type RecipientGetEmailContextOptions = BaseGetEmailContextOptions & {
type GetEmailContextOptions = InternalGetEmailContextOptions | RecipientGetEmailContextOptions;
type EmailContextResponse = {
export type EmailContextResponse = {
allowedEmails: OrganisationEmail[];
branding: BrandingSettings;
settings: Omit<OrganisationGlobalSettings, 'id'>;
@@ -0,0 +1,49 @@
import { mailer } from '@documenso/email/mailer';
import { OrganisationDeleteEmailTemplate } from '@documenso/email/templates/organisation-delete';
import { msg } from '@lingui/core/macro';
import { createElement } from 'react';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import type { EmailContextResponse } from '../email/get-email-context';
export type SendOrganisationDeleteEmailOptions = {
email: string;
organisationName: string;
deletedByAdmin?: boolean;
emailContext: EmailContextResponse;
};
/**
* Sends an "organisation deleted" notification email.
*/
export const sendOrganisationDeleteEmail = async ({
email,
organisationName,
deletedByAdmin = false,
emailContext,
}: SendOrganisationDeleteEmailOptions) => {
const template = createElement(OrganisationDeleteEmailTemplate, {
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
organisationName,
deletedByAdmin,
});
const { branding, emailLanguage, senderEmail } = emailContext;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
]);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: email,
from: senderEmail,
subject: i18n._(msg`Organisation "${organisationName}" has been deleted`),
html,
text,
});
};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+8 -2
View File
@@ -20,5 +20,11 @@ export const env = <K extends EnvKey>(variable: K): EnvValue<K> => {
return (typeof process !== 'undefined' ? process?.env?.[variable] : undefined) as EnvValue<K>;
};
export const createPublicEnv = () =>
Object.fromEntries(Object.entries(process.env).filter(([key]) => key.startsWith('NEXT_PUBLIC_')));
export const createPublicEnv = () => ({
...Object.fromEntries(Object.entries(process.env).filter(([key]) => key.startsWith('NEXT_PUBLIC_'))),
// Derived from the private URL so the public flag cannot drift from the
// real server-side configuration. Placed last so it wins over any literal
// env var with the same name.
// The `? 'true' : 'false'` might seem dumb but it's because we're expecting env var strings.
NEXT_PUBLIC_DOCUMENT_CONVERSION_ENABLED: process.env.NEXT_PRIVATE_DOCUMENT_CONVERSION_URL ? 'true' : 'false',
});