mirror of
https://github.com/papra-hq/papra.git
synced 2025-12-17 03:51:45 -06:00
Compare commits
3 Commits
@papra/app
...
@papra/app
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1abbf18e94 | ||
|
|
6bcb2a71e9 | ||
|
|
936bc2bd0a |
@@ -1,5 +1,11 @@
|
||||
# @papra/app-client
|
||||
|
||||
## 0.9.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#506](https://github.com/papra-hq/papra/pull/506) [`6bcb2a7`](https://github.com/papra-hq/papra/commit/6bcb2a71e990d534dd12d84e64a38f2b2baea25a) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added the possibility to define patterns for email intake username generation
|
||||
|
||||
## 0.9.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@papra/app-client",
|
||||
"type": "module",
|
||||
"version": "0.9.2",
|
||||
"version": "0.9.3",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.12.3",
|
||||
"description": "Papra frontend client",
|
||||
|
||||
@@ -541,7 +541,8 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
'api-errors.document.already_exists': 'Das Dokument existiert bereits',
|
||||
'api-errors.document.size_too_large': 'Die Datei ist zu groß',
|
||||
'api-errors.intake_email.limit_reached': 'Die maximale Anzahl an Eingangse-Mails für diese Organisation wurde erreicht. Bitte aktualisieren Sie Ihren Plan, um weitere Eingangse-Mails zu erstellen.',
|
||||
'api-errors.intake-emails.already_exists': 'Eine Eingang-Email mit dieser Adresse existiert bereits.',
|
||||
'api-errors.intake_email.limit_reached': 'Die maximale Anzahl an Eingang-EMails für diese Organisation wurde erreicht. Bitte aktualisieren Sie Ihren Plan, um weitere Eingangse-Mails zu erstellen.',
|
||||
'api-errors.user.max_organization_count_reached': 'Sie haben die maximale Anzahl an Organisationen erreicht, die Sie erstellen können. Wenn Sie weitere erstellen möchten, kontaktieren Sie bitte den Support.',
|
||||
'api-errors.default': 'Beim Verarbeiten Ihrer Anfrage ist ein Fehler aufgetreten.',
|
||||
'api-errors.organization.invitation_already_exists': 'Eine Einladung für diese E-Mail existiert bereits in dieser Organisation.',
|
||||
|
||||
@@ -539,6 +539,7 @@ export const translations = {
|
||||
|
||||
'api-errors.document.already_exists': 'The document already exists',
|
||||
'api-errors.document.size_too_large': 'The file size is too large',
|
||||
'api-errors.intake-emails.already_exists': 'An intake email with this address already exists.',
|
||||
'api-errors.intake_email.limit_reached': 'The maximum number of intake emails for this organization has been reached. Please upgrade your plan to create more intake emails.',
|
||||
'api-errors.user.max_organization_count_reached': 'You have reached the maximum number of organizations you can create, if you need to create more, please contact support.',
|
||||
'api-errors.default': 'An error occurred while processing your request.',
|
||||
|
||||
@@ -541,6 +541,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
'api-errors.document.already_exists': 'El documento ya existe',
|
||||
'api-errors.document.size_too_large': 'El archivo es demasiado grande',
|
||||
'api-errors.intake-emails.already_exists': 'Ya existe un correo de ingreso con esta dirección.',
|
||||
'api-errors.intake_email.limit_reached': 'Se ha alcanzado el número máximo de correos de ingreso para esta organización. Por favor, mejora tu plan para crear más correos de ingreso.',
|
||||
'api-errors.user.max_organization_count_reached': 'Has alcanzado el número máximo de organizaciones que puedes crear, si necesitas crear más, contacta al soporte.',
|
||||
'api-errors.default': 'Ocurrió un error al procesar tu solicitud.',
|
||||
|
||||
@@ -541,6 +541,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
'api-errors.document.already_exists': 'Le document existe déjà',
|
||||
'api-errors.document.size_too_large': 'Le fichier est trop volumineux',
|
||||
'api-errors.intake-emails.already_exists': 'Un email de réception avec cette adresse existe déjà.',
|
||||
'api-errors.intake_email.limit_reached': 'Le nombre maximum d\'emails de réception pour cette organisation a été atteint. Veuillez mettre à niveau votre plan pour créer plus d\'emails de réception.',
|
||||
'api-errors.user.max_organization_count_reached': 'Vous avez atteint le nombre maximum d\'organisations que vous pouvez créer, si vous avez besoin de créer plus, veuillez contacter le support.',
|
||||
'api-errors.default': 'Une erreur est survenue lors du traitement de votre requête.',
|
||||
|
||||
@@ -541,6 +541,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
'api-errors.document.already_exists': 'Il documento esiste già',
|
||||
'api-errors.document.size_too_large': 'Il file è troppo grande',
|
||||
'api-errors.intake-emails.already_exists': 'Un\'email di acquisizione con questo indirizzo esiste già.',
|
||||
'api-errors.intake_email.limit_reached': 'È stato raggiunto il numero massimo di email di acquisizione per questa organizzazione. Aggiorna il tuo piano per crearne altre.',
|
||||
'api-errors.user.max_organization_count_reached': 'Hai raggiunto il numero massimo di organizzazioni che puoi creare, se hai bisogno di crearne altre contatta il supporto.',
|
||||
'api-errors.default': 'Si è verificato un errore durante l\'elaborazione della richiesta.',
|
||||
|
||||
@@ -541,6 +541,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
'api-errors.document.already_exists': 'Dokument już istnieje',
|
||||
'api-errors.document.size_too_large': 'Plik jest zbyt duży',
|
||||
'api-errors.intake-emails.already_exists': 'Adres e-mail do przyjęć z tym adresem już istnieje.',
|
||||
'api-errors.intake_email.limit_reached': 'Osiągnięto maksymalną liczbę adresów e-mail do przyjęć dla tej organizacji. Aby utworzyć więcej adresów e-mail do przyjęć, zaktualizuj swój plan.',
|
||||
'api-errors.user.max_organization_count_reached': 'Osiągnięto maksymalną liczbę organizacji, które możesz utworzyć. Jeśli potrzebujesz utworzyć więcej, skontaktuj się z pomocą techniczną.',
|
||||
'api-errors.default': 'Wystąpił błąd podczas przetwarzania żądania.',
|
||||
|
||||
@@ -541,6 +541,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
'api-errors.document.already_exists': 'O documento já existe',
|
||||
'api-errors.document.size_too_large': 'O arquivo é muito grande',
|
||||
'api-errors.intake-emails.already_exists': 'Um e-mail de entrada com este endereço já existe.',
|
||||
'api-errors.intake_email.limit_reached': 'O número máximo de e-mails de entrada para esta organização foi atingido. Faça um upgrade no seu plano para criar mais e-mails de entrada.',
|
||||
'api-errors.user.max_organization_count_reached': 'Você atingiu o número máximo de organizações que pode criar. Se precisar criar mais, entre em contato com o suporte.',
|
||||
'api-errors.default': 'Ocorreu um erro ao processar sua solicitação.',
|
||||
|
||||
@@ -541,6 +541,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
'api-errors.document.already_exists': 'O documento já existe',
|
||||
'api-errors.document.size_too_large': 'O arquivo é muito grande',
|
||||
'api-errors.intake-emails.already_exists': 'Um e-mail de entrada com este endereço já existe.',
|
||||
'api-errors.intake_email.limit_reached': 'O número máximo de e-mails de entrada para esta organização foi atingido. Faça um upgrade no seu plano para criar mais e-mails de entrada.',
|
||||
'api-errors.user.max_organization_count_reached': 'Atingiu o número máximo de organizações que pode criar. Se precisar de criar mais, entre em contato com o suporte.',
|
||||
'api-errors.default': 'Ocorreu um erro ao processar a solicitação.',
|
||||
|
||||
@@ -541,6 +541,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
'api-errors.document.already_exists': 'Documentul există deja',
|
||||
'api-errors.document.size_too_large': 'Fișierul este prea mare',
|
||||
'api-errors.intake-emails.already_exists': 'Un email de primire cu această adresă există deja.',
|
||||
'api-errors.intake_email.limit_reached': 'Numărul maxim de email-uri de primire pentru această organizație a fost atins. Te rugăm să-ți îmbunătățești planul pentru a crea mai multe email-uri de primire.',
|
||||
'api-errors.user.max_organization_count_reached': 'Ai atins numărul maxim de organizații pe care le poți crea. Dacă ai nevoie să creezi mai multe, te rugăm să contactezi asistența.',
|
||||
'api-errors.default': 'A apărut o eroare la procesarea cererii.',
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useConfig } from '@/modules/config/config.provider';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { useConfirmModal } from '@/modules/shared/confirm';
|
||||
import { createForm } from '@/modules/shared/form/form';
|
||||
import { isHttpErrorWithCode } from '@/modules/shared/http/http-errors';
|
||||
import { useI18nApiErrors } from '@/modules/shared/http/composables/i18n-api-errors';
|
||||
import { queryClient } from '@/modules/shared/query/query-client';
|
||||
import { cn } from '@/modules/shared/style/cn';
|
||||
import { Alert, AlertDescription } from '@/modules/ui/components/alert';
|
||||
@@ -187,6 +187,7 @@ export const IntakeEmailsPage: Component = () => {
|
||||
|
||||
const params = useParams();
|
||||
const { confirm } = useConfirmModal();
|
||||
const { getErrorMessage } = useI18nApiErrors({ t });
|
||||
|
||||
const query = useQuery(() => ({
|
||||
queryKey: ['organizations', params.organizationId, 'intake-emails'],
|
||||
@@ -196,16 +197,12 @@ export const IntakeEmailsPage: Component = () => {
|
||||
const createEmail = async () => {
|
||||
const [,error] = await safely(createIntakeEmail({ organizationId: params.organizationId }));
|
||||
|
||||
if (isHttpErrorWithCode({ error, code: 'intake_email.limit_reached' })) {
|
||||
if (error) {
|
||||
createToast({
|
||||
message: t('api-errors.intake_email.limit_reached'),
|
||||
message: getErrorMessage({ error }),
|
||||
type: 'error',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { TranslationKeys } from '@/modules/i18n/locales.types';
|
||||
import { get } from 'lodash-es';
|
||||
import { FetchError } from 'ofetch';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
|
||||
function codeToKey(code: string): TranslationKeys {
|
||||
@@ -30,6 +31,11 @@ export function useI18nApiErrors({ t = useI18n().t }: { t?: ReturnType<typeof us
|
||||
return translation;
|
||||
}
|
||||
|
||||
// Fetch error message is not helpful
|
||||
if (error instanceof FetchError) {
|
||||
return getDefaultErrorMessage();
|
||||
}
|
||||
|
||||
if (typeof error === 'object' && error && 'message' in error && typeof error.message === 'string') {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
# @papra/app-server
|
||||
|
||||
## 0.9.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#506](https://github.com/papra-hq/papra/pull/506) [`6bcb2a7`](https://github.com/papra-hq/papra/commit/6bcb2a71e990d534dd12d84e64a38f2b2baea25a) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added the possibility to define patterns for email intake username generation
|
||||
|
||||
- [#504](https://github.com/papra-hq/papra/pull/504) [`936bc2b`](https://github.com/papra-hq/papra/commit/936bc2bd0a788e4fb0bceb6d14810f9f8734097b) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Split the intake-email username generation from the email address creation, some changes regarding the configuration when using the `random` driver.
|
||||
|
||||
```env
|
||||
# Old configuration
|
||||
INTAKE_EMAILS_DRIVER=random-username
|
||||
INTAKE_EMAILS_EMAIL_GENERATION_DOMAIN=mydomain.com
|
||||
|
||||
# New configuration
|
||||
INTAKE_EMAILS_DRIVER=catch-all
|
||||
INTAKE_EMAILS_CATCH_ALL_DOMAIN=mydomain.com
|
||||
INTAKE_EMAILS_USERNAME_DRIVER=random
|
||||
```
|
||||
|
||||
- [#504](https://github.com/papra-hq/papra/pull/504) [`936bc2b`](https://github.com/papra-hq/papra/commit/936bc2bd0a788e4fb0bceb6d14810f9f8734097b) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added the possibility to configure OwlRelay domain
|
||||
|
||||
## 0.9.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@papra/app-server",
|
||||
"type": "module",
|
||||
"version": "0.9.2",
|
||||
"version": "0.9.3",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.12.3",
|
||||
"description": "Papra app server",
|
||||
@@ -55,6 +55,7 @@
|
||||
"@papra/lecture": "workspace:*",
|
||||
"@papra/webhooks": "workspace:*",
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"@sindresorhus/slugify": "^3.0.0",
|
||||
"better-auth": "catalog:",
|
||||
"busboy": "^1.6.0",
|
||||
"c12": "^3.0.4",
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { ConfigDefinition } from 'figue';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
export const randomUsernameIntakeEmailDriverConfig = {
|
||||
export const catchAllIntakeEmailDriverConfig = {
|
||||
domain: {
|
||||
doc: 'The domain to use when generating email addresses for intake emails when using the random username driver',
|
||||
doc: 'The domain to use when generating email addresses for intake emails when using the `catch-all` driver',
|
||||
schema: z.string(),
|
||||
default: 'papra.email',
|
||||
env: 'INTAKE_EMAILS_EMAIL_GENERATION_DOMAIN',
|
||||
default: 'papra.local',
|
||||
env: 'INTAKE_EMAILS_CATCH_ALL_DOMAIN',
|
||||
},
|
||||
} as const satisfies ConfigDefinition;
|
||||
@@ -0,0 +1,20 @@
|
||||
import { buildEmailAddress } from '../../intake-emails.models';
|
||||
import { defineIntakeEmailDriver } from '../intake-emails.drivers.models';
|
||||
|
||||
export const CATCH_ALL_INTAKE_EMAIL_DRIVER_NAME = 'catch-all';
|
||||
|
||||
// This driver is used when no external service is used to manage the email addresses
|
||||
// like for example when using a catch-all domain
|
||||
export const catchAllIntakeEmailDriverFactory = defineIntakeEmailDriver(({ config }) => {
|
||||
const { domain } = config.intakeEmails.drivers.catchAll;
|
||||
|
||||
return {
|
||||
name: CATCH_ALL_INTAKE_EMAIL_DRIVER_NAME,
|
||||
createEmailAddress: async ({ username }) => {
|
||||
const emailAddress = buildEmailAddress({ username, domain });
|
||||
|
||||
return { emailAddress };
|
||||
},
|
||||
deleteEmailAddress: async () => {},
|
||||
};
|
||||
});
|
||||
@@ -2,8 +2,8 @@ import type { Config } from '../../config/config.types';
|
||||
|
||||
export type IntakeEmailsServices = {
|
||||
name: string;
|
||||
generateEmailAddress: () => Promise<{ emailAddress: string }>;
|
||||
deleteEmailAddress: ({ emailAddress }: { emailAddress: string }) => Promise<void>;
|
||||
createEmailAddress: (args: { username: string }) => Promise<{ emailAddress: string }>;
|
||||
deleteEmailAddress: (args: { emailAddress: string }) => Promise<void>;
|
||||
};
|
||||
|
||||
export type IntakeEmailDriverFactory = (args: { config: Config }) => IntakeEmailsServices;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { CATCH_ALL_INTAKE_EMAIL_DRIVER_NAME, catchAllIntakeEmailDriverFactory } from './catch-all/catch-all.intake-email-driver';
|
||||
import { OWLRELAY_INTAKE_EMAIL_DRIVER_NAME, owlrelayIntakeEmailDriverFactory } from './owlrelay/owlrelay.intake-email-driver';
|
||||
import { RANDOM_USERNAME_INTAKE_EMAIL_DRIVER_NAME, randomUsernameIntakeEmailDriverFactory } from './random-username/random-username.intake-email-driver';
|
||||
|
||||
export const intakeEmailDrivers = {
|
||||
[RANDOM_USERNAME_INTAKE_EMAIL_DRIVER_NAME]: randomUsernameIntakeEmailDriverFactory,
|
||||
[OWLRELAY_INTAKE_EMAIL_DRIVER_NAME]: owlrelayIntakeEmailDriverFactory,
|
||||
[CATCH_ALL_INTAKE_EMAIL_DRIVER_NAME]: catchAllIntakeEmailDriverFactory,
|
||||
} as const;
|
||||
|
||||
export type IntakeEmailDriverName = keyof typeof intakeEmailDrivers;
|
||||
|
||||
@@ -15,4 +15,10 @@ export const owlrelayIntakeEmailDriverConfig = {
|
||||
default: undefined,
|
||||
env: 'OWLRELAY_WEBHOOK_URL',
|
||||
},
|
||||
domain: {
|
||||
doc: 'The domain to use when generating email addresses for intake emails with OwlRelay, if not provided, the OwlRelay will use their default domain',
|
||||
schema: z.string().optional(), // TODO: check valid hostname
|
||||
default: undefined,
|
||||
env: 'OWLRELAY_DOMAIN',
|
||||
},
|
||||
} as const satisfies ConfigDefinition;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { buildUrl, safely } from '@corentinth/chisels';
|
||||
import { generateId as generateHumanReadableId } from '@corentinth/friendly-ids';
|
||||
import { createClient } from '@owlrelay/api-sdk';
|
||||
import { getServerBaseUrl } from '../../../config/config.models';
|
||||
import { createError } from '../../../shared/errors/errors';
|
||||
import { createLogger } from '../../../shared/logger/logger';
|
||||
import { INTAKE_EMAILS_INGEST_ROUTE } from '../../intake-emails.constants';
|
||||
import { buildEmailAddress } from '../../intake-emails.models';
|
||||
@@ -14,24 +14,35 @@ const logger = createLogger({ namespace: 'intake-emails.drivers.owlrelay' });
|
||||
export const owlrelayIntakeEmailDriverFactory = defineIntakeEmailDriver(({ config }) => {
|
||||
const { serverBaseUrl } = getServerBaseUrl({ config });
|
||||
const { webhookSecret } = config.intakeEmails;
|
||||
const { owlrelayApiKey, webhookUrl: configuredWebhookUrl } = config.intakeEmails.drivers.owlrelay;
|
||||
const { owlrelayApiKey, webhookUrl: configuredWebhookUrl, domain } = config.intakeEmails.drivers.owlrelay;
|
||||
|
||||
const client = createClient({
|
||||
apiKey: owlrelayApiKey,
|
||||
});
|
||||
const client = createClient({ apiKey: owlrelayApiKey });
|
||||
|
||||
const webhookUrl = configuredWebhookUrl ?? buildUrl({ baseUrl: serverBaseUrl, path: INTAKE_EMAILS_INGEST_ROUTE });
|
||||
|
||||
return {
|
||||
name: OWLRELAY_INTAKE_EMAIL_DRIVER_NAME,
|
||||
generateEmailAddress: async () => {
|
||||
const { domain, username, id: owlrelayEmailId } = await client.createEmail({
|
||||
username: generateHumanReadableId(),
|
||||
createEmailAddress: async ({ username }) => {
|
||||
const [result, error] = await safely(client.createEmail({
|
||||
username,
|
||||
webhookUrl,
|
||||
webhookSecret,
|
||||
});
|
||||
domain,
|
||||
}));
|
||||
|
||||
const emailAddress = buildEmailAddress({ username, domain });
|
||||
if (error) {
|
||||
logger.error({ error, username }, 'Failed to create email address in OwlRelay');
|
||||
|
||||
throw createError({
|
||||
code: 'intake_emails.create_email_address_failed',
|
||||
message: 'Failed to create email address in OwlRelay',
|
||||
statusCode: 500,
|
||||
isInternal: true,
|
||||
});
|
||||
}
|
||||
|
||||
const { id: owlrelayEmailId, username: createdAddressUsername, domain: createdAddressDomain } = result;
|
||||
const emailAddress = buildEmailAddress({ username: createdAddressUsername, domain: createdAddressDomain });
|
||||
|
||||
logger.info({ emailAddress, owlrelayEmailId }, 'Created email address in OwlRelay');
|
||||
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { generateId as generateHumanReadableId } from '@corentinth/friendly-ids';
|
||||
import { defineIntakeEmailDriver } from '../intake-emails.drivers.models';
|
||||
|
||||
export const RANDOM_USERNAME_INTAKE_EMAIL_DRIVER_NAME = 'random-username';
|
||||
|
||||
export const randomUsernameIntakeEmailDriverFactory = defineIntakeEmailDriver(({ config }) => {
|
||||
const { domain } = config.intakeEmails.drivers.randomUsername;
|
||||
|
||||
return {
|
||||
name: RANDOM_USERNAME_INTAKE_EMAIL_DRIVER_NAME,
|
||||
generateEmailAddress: async () => {
|
||||
const randomUsername = generateHumanReadableId();
|
||||
|
||||
return {
|
||||
emailAddress: `${randomUsername}@${domain}`,
|
||||
};
|
||||
},
|
||||
// Deletion functionality is not required for this driver
|
||||
deleteEmailAddress: async () => {},
|
||||
};
|
||||
});
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { ConfigDefinition } from 'figue';
|
||||
import { z } from 'zod';
|
||||
import { booleanishSchema } from '../config/config.schemas';
|
||||
import { CATCH_ALL_INTAKE_EMAIL_DRIVER_NAME } from './drivers/catch-all/catch-all.intake-email-driver';
|
||||
import { catchAllIntakeEmailDriverConfig } from './drivers/catch-all/catch-all.intake-email-driver.config';
|
||||
import { intakeEmailDrivers } from './drivers/intake-emails.drivers';
|
||||
import { owlrelayIntakeEmailDriverConfig } from './drivers/owlrelay/owlrelay.intake-email-driver.config';
|
||||
import { RANDOM_USERNAME_INTAKE_EMAIL_DRIVER_NAME } from './drivers/random-username/random-username.intake-email-driver';
|
||||
import { randomUsernameIntakeEmailDriverConfig } from './drivers/random-username/random-username.intake-email-driver.config';
|
||||
import { intakeEmailUsernameConfig } from './username-drivers/intake-email-username.config';
|
||||
|
||||
export const intakeEmailsConfig = {
|
||||
isEnabled: {
|
||||
@@ -13,20 +14,21 @@ export const intakeEmailsConfig = {
|
||||
default: false,
|
||||
env: 'INTAKE_EMAILS_IS_ENABLED',
|
||||
},
|
||||
driver: {
|
||||
doc: `The driver to use when generating email addresses for intake emails, value can be one of: ${Object.keys(intakeEmailDrivers).map(x => `\`${x}\``).join(', ')}`,
|
||||
schema: z.enum(Object.keys(intakeEmailDrivers) as [string, ...string[]]),
|
||||
default: RANDOM_USERNAME_INTAKE_EMAIL_DRIVER_NAME,
|
||||
env: 'INTAKE_EMAILS_DRIVER',
|
||||
},
|
||||
webhookSecret: {
|
||||
doc: 'The secret to use when verifying webhooks',
|
||||
schema: z.string(),
|
||||
default: 'change-me',
|
||||
env: 'INTAKE_EMAILS_WEBHOOK_SECRET',
|
||||
},
|
||||
drivers: {
|
||||
randomUsername: randomUsernameIntakeEmailDriverConfig,
|
||||
owlrelay: owlrelayIntakeEmailDriverConfig,
|
||||
driver: {
|
||||
doc: `The driver to use when generating email addresses for intake emails, value can be one of: ${Object.keys(intakeEmailDrivers).map(x => `\`${x}\``).join(', ')}.`,
|
||||
schema: z.enum(Object.keys(intakeEmailDrivers) as [string, ...string[]]),
|
||||
default: CATCH_ALL_INTAKE_EMAIL_DRIVER_NAME,
|
||||
env: 'INTAKE_EMAILS_DRIVER',
|
||||
},
|
||||
drivers: {
|
||||
owlrelay: owlrelayIntakeEmailDriverConfig,
|
||||
catchAll: catchAllIntakeEmailDriverConfig,
|
||||
},
|
||||
username: intakeEmailUsernameConfig,
|
||||
} as const satisfies ConfigDefinition;
|
||||
|
||||
@@ -11,3 +11,9 @@ export const createIntakeEmailNotFoundError = createErrorFactory({
|
||||
code: 'intake_email.not_found',
|
||||
statusCode: 404,
|
||||
});
|
||||
|
||||
export const createIntakeEmailAlreadyExistsError = createErrorFactory({
|
||||
message: 'Intake email already exists',
|
||||
code: 'intake_email.already_exists',
|
||||
statusCode: 400,
|
||||
});
|
||||
|
||||
@@ -27,6 +27,14 @@ export function parseEmailAddress({ email }: { email: string }) {
|
||||
const [username, ...plusParts] = fullUsername.split('+');
|
||||
const plusPart = plusParts.length > 0 ? plusParts.join('+') : undefined;
|
||||
|
||||
if (isNil(username)) {
|
||||
throw createError({
|
||||
message: 'Badly formatted email address',
|
||||
code: 'intake_emails.badly_formatted_email_address',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return { username, domain, plusPart };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { Database } from '../app/database/database.types';
|
||||
import { injectArguments } from '@corentinth/chisels';
|
||||
import { injectArguments, safely } from '@corentinth/chisels';
|
||||
import { and, count, eq } from 'drizzle-orm';
|
||||
import { isUniqueConstraintError } from '../shared/db/constraints.models';
|
||||
import { createError } from '../shared/errors/errors';
|
||||
import { omitUndefined } from '../shared/utils';
|
||||
import { createIntakeEmailNotFoundError } from './intake-emails.errors';
|
||||
import { createIntakeEmailAlreadyExistsError, createIntakeEmailNotFoundError } from './intake-emails.errors';
|
||||
import { intakeEmailsTable } from './intake-emails.tables';
|
||||
|
||||
export type IntakeEmailsRepository = ReturnType<typeof createIntakeEmailsRepository>;
|
||||
@@ -24,7 +25,17 @@ export function createIntakeEmailsRepository({ db }: { db: Database }) {
|
||||
}
|
||||
|
||||
async function createIntakeEmail({ organizationId, emailAddress, db }: { organizationId: string; emailAddress: string; db: Database }) {
|
||||
const [intakeEmail] = await db.insert(intakeEmailsTable).values({ organizationId, emailAddress }).returning();
|
||||
const [result, error] = await safely(db.insert(intakeEmailsTable).values({ organizationId, emailAddress }).returning());
|
||||
|
||||
if (isUniqueConstraintError({ error })) {
|
||||
throw createIntakeEmailAlreadyExistsError();
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const [intakeEmail] = result;
|
||||
|
||||
if (!intakeEmail) {
|
||||
// Very unlikely to happen as the insertion should throw an issue, it's for type safety
|
||||
|
||||
@@ -15,11 +15,13 @@ import { createLogger } from '../shared/logger/logger';
|
||||
import { isNil } from '../shared/utils';
|
||||
import { validateFormData, validateJsonBody, validateParams } from '../shared/validation/validation';
|
||||
import { createSubscriptionsRepository } from '../subscriptions/subscriptions.repository';
|
||||
import { createUsersRepository } from '../users/users.repository';
|
||||
import { INTAKE_EMAILS_INGEST_ROUTE } from './intake-emails.constants';
|
||||
import { createIntakeEmailsRepository } from './intake-emails.repository';
|
||||
import { allowedOriginsSchema, intakeEmailIdSchema, intakeEmailsIngestionMetaSchema, parseJson } from './intake-emails.schemas';
|
||||
import { createIntakeEmailsServices } from './intake-emails.services';
|
||||
import { createIntakeEmail, deleteIntakeEmail, processIntakeEmailIngestion } from './intake-emails.usecases';
|
||||
import { createIntakeEmailUsernameServices } from './username-drivers/intake-email-username.services';
|
||||
|
||||
const logger = createLogger({ namespace: 'intake-emails.routes' });
|
||||
|
||||
@@ -65,20 +67,24 @@ function setupCreateIntakeEmailRoute({ app, db, config }: RouteDefinitionContext
|
||||
const { userId } = getUser({ context });
|
||||
const { organizationId } = context.req.valid('param');
|
||||
|
||||
const usersRepository = createUsersRepository({ db });
|
||||
const organizationsRepository = createOrganizationsRepository({ db });
|
||||
const intakeEmailsRepository = createIntakeEmailsRepository({ db });
|
||||
const intakeEmailsServices = createIntakeEmailsServices({ config });
|
||||
const plansRepository = createPlansRepository({ config });
|
||||
const subscriptionsRepository = createSubscriptionsRepository({ db });
|
||||
const intakeEmailUsernameServices = createIntakeEmailUsernameServices({ config, usersRepository, organizationsRepository });
|
||||
|
||||
await ensureUserIsInOrganization({ userId, organizationId, organizationsRepository });
|
||||
|
||||
const { intakeEmail } = await createIntakeEmail({
|
||||
userId,
|
||||
organizationId,
|
||||
intakeEmailsRepository,
|
||||
intakeEmailsServices,
|
||||
plansRepository,
|
||||
subscriptionsRepository,
|
||||
intakeEmailUsernameServices,
|
||||
});
|
||||
|
||||
return context.json({ intakeEmail });
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Logger } from '../shared/logger/logger';
|
||||
import type { SubscriptionsRepository } from '../subscriptions/subscriptions.repository';
|
||||
import type { IntakeEmailsServices } from './drivers/intake-emails.drivers.models';
|
||||
import type { IntakeEmailsRepository } from './intake-emails.repository';
|
||||
import type { IntakeEmailUsernameServices } from './username-drivers/intake-email-username.services';
|
||||
import { safely } from '@corentinth/chisels';
|
||||
import { getOrganizationPlan } from '../plans/plans.usecases';
|
||||
import { addLogContext, createLogger } from '../shared/logger/logger';
|
||||
@@ -12,17 +13,21 @@ import { createIntakeEmailLimitReachedError, createIntakeEmailNotFoundError } fr
|
||||
import { getIsFromAllowedOrigin } from './intake-emails.models';
|
||||
|
||||
export async function createIntakeEmail({
|
||||
userId,
|
||||
organizationId,
|
||||
intakeEmailsRepository,
|
||||
intakeEmailsServices,
|
||||
plansRepository,
|
||||
subscriptionsRepository,
|
||||
intakeEmailUsernameServices,
|
||||
}: {
|
||||
userId: string;
|
||||
organizationId: string;
|
||||
intakeEmailsRepository: IntakeEmailsRepository;
|
||||
intakeEmailsServices: IntakeEmailsServices;
|
||||
plansRepository: PlansRepository;
|
||||
subscriptionsRepository: SubscriptionsRepository;
|
||||
intakeEmailUsernameServices: IntakeEmailUsernameServices;
|
||||
}) {
|
||||
await checkIfOrganizationCanCreateNewIntakeEmail({
|
||||
organizationId,
|
||||
@@ -31,7 +36,9 @@ export async function createIntakeEmail({
|
||||
intakeEmailsRepository,
|
||||
});
|
||||
|
||||
const { emailAddress } = await intakeEmailsServices.generateEmailAddress();
|
||||
const { username } = await intakeEmailUsernameServices.generateIntakeEmailUsername({ userId, organizationId });
|
||||
|
||||
const { emailAddress } = await intakeEmailsServices.createEmailAddress({ username });
|
||||
|
||||
const { intakeEmail } = await intakeEmailsRepository.createIntakeEmail({ organizationId, emailAddress });
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { ConfigDefinition } from 'figue';
|
||||
import { z } from 'zod';
|
||||
import { intakeEmailUsernameDrivers } from './intake-email-username.drivers';
|
||||
import { patternIntakeEmailDriverConfig } from './pattern/pattern.intake-email-username-driver.config';
|
||||
import { RANDOM_INTAKE_EMAIL_ADDRESSES_DRIVER_NAME } from './random/random.intake-email-username-driver';
|
||||
|
||||
export const intakeEmailUsernameConfig = {
|
||||
driver: {
|
||||
doc: `The driver to use when generating email addresses for intake emails, value can be one of: ${Object.keys(intakeEmailUsernameDrivers).map(x => `\`${x}\``).join(', ')}`,
|
||||
schema: z.enum(Object.keys(intakeEmailUsernameDrivers) as [string, ...string[]]),
|
||||
default: RANDOM_INTAKE_EMAIL_ADDRESSES_DRIVER_NAME,
|
||||
env: 'INTAKE_EMAILS_USERNAME_DRIVER',
|
||||
},
|
||||
drivers: {
|
||||
pattern: patternIntakeEmailDriverConfig,
|
||||
},
|
||||
} as const satisfies ConfigDefinition;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { patternIntakeEmailUsernameDriverFactory } from './pattern/pattern.intake-email-username-driver';
|
||||
import { PATTERN_INTAKE_EMAIL_ADDRESSES_DRIVER_NAME } from './pattern/pattern.intake-email-username-driver.config';
|
||||
import { RANDOM_INTAKE_EMAIL_ADDRESSES_DRIVER_NAME, randomIntakeEmailUsernameDriverFactory } from './random/random.intake-email-username-driver';
|
||||
|
||||
export const intakeEmailUsernameDrivers = {
|
||||
[RANDOM_INTAKE_EMAIL_ADDRESSES_DRIVER_NAME]: randomIntakeEmailUsernameDriverFactory,
|
||||
[PATTERN_INTAKE_EMAIL_ADDRESSES_DRIVER_NAME]: patternIntakeEmailUsernameDriverFactory,
|
||||
} as const;
|
||||
|
||||
export type IntakeEmailUsernameDriverName = keyof typeof intakeEmailUsernameDrivers;
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Logger } from '@crowlog/logger';
|
||||
import type { Config } from '../../config/config.types';
|
||||
import type { OrganizationsRepository } from '../../organizations/organizations.repository';
|
||||
import type { UsersRepository } from '../../users/users.repository';
|
||||
|
||||
export type IntakeEmailUsernameDriver = {
|
||||
name: string;
|
||||
generateIntakeEmailUsername: (args: { userId: string; organizationId: string }) => Promise<{ username: string }>;
|
||||
};
|
||||
|
||||
export type IntakeEmailUsernameDriverFactory = (args: {
|
||||
config: Config;
|
||||
logger?: Logger;
|
||||
usersRepository: UsersRepository;
|
||||
organizationsRepository: OrganizationsRepository;
|
||||
}) => IntakeEmailUsernameDriver;
|
||||
|
||||
export function defineIntakeEmailUsernameDriverFactory(factory: IntakeEmailUsernameDriverFactory) {
|
||||
return factory;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Config } from '../../config/config.types';
|
||||
import type { OrganizationsRepository } from '../../organizations/organizations.repository';
|
||||
import type { UsersRepository } from '../../users/users.repository';
|
||||
import type { IntakeEmailUsernameDriverName } from './intake-email-username.drivers';
|
||||
import type { IntakeEmailUsernameDriver, IntakeEmailUsernameDriverFactory } from './intake-email-username.models';
|
||||
import { createError } from '../../shared/errors/errors';
|
||||
import { isNil } from '../../shared/utils';
|
||||
import { intakeEmailUsernameDrivers } from './intake-email-username.drivers';
|
||||
|
||||
export type IntakeEmailUsernameServices = IntakeEmailUsernameDriver;
|
||||
|
||||
export function createIntakeEmailUsernameServices({
|
||||
config,
|
||||
...dependencies
|
||||
}: {
|
||||
config: Config;
|
||||
usersRepository: UsersRepository;
|
||||
organizationsRepository: OrganizationsRepository;
|
||||
}) {
|
||||
const { driver } = config.intakeEmails.username;
|
||||
const intakeEmailUsernameDriver: IntakeEmailUsernameDriverFactory | undefined = intakeEmailUsernameDrivers[driver as IntakeEmailUsernameDriverName];
|
||||
|
||||
if (isNil(intakeEmailUsernameDriver)) {
|
||||
throw createError({
|
||||
message: `Invalid intake email addresses driver ${driver}`,
|
||||
code: 'intake-emails.addresses.invalid_driver',
|
||||
statusCode: 500,
|
||||
isInternal: true,
|
||||
});
|
||||
}
|
||||
|
||||
const intakeEmailUsernameServices = intakeEmailUsernameDriver({ config, ...dependencies });
|
||||
|
||||
return intakeEmailUsernameServices;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { ConfigDefinition } from 'figue';
|
||||
|
||||
import { z } from 'zod';
|
||||
import { PATTERNS_PLACEHOLDERS } from './pattern.intake-email-username-driver.constants';
|
||||
|
||||
export const PATTERN_INTAKE_EMAIL_ADDRESSES_DRIVER_NAME = 'pattern';
|
||||
|
||||
export const patternIntakeEmailDriverConfig = {
|
||||
pattern: {
|
||||
doc: `The pattern to use when generating email addresses usernames (before the @) for intake emails. Available placeholders are: ${Object.values(PATTERNS_PLACEHOLDERS).join(', ')}. Note: the resulting username will be slugified to remove special characters and spaces.`,
|
||||
schema: z.string(),
|
||||
default: `${PATTERNS_PLACEHOLDERS.USER_NAME}-${PATTERNS_PLACEHOLDERS.RANDOM_DIGITS}`,
|
||||
env: 'INTAKE_EMAILS_USERNAME_DRIVER_PATTERN',
|
||||
},
|
||||
} as const satisfies ConfigDefinition;
|
||||
@@ -0,0 +1,8 @@
|
||||
export const PATTERNS_PLACEHOLDERS = {
|
||||
USER_NAME: '{{user.name}}',
|
||||
USER_ID: '{{user.id}}',
|
||||
USER_EMAIL_USERNAME: '{{user.email.username}}',
|
||||
ORGANIZATION_ID: '{{organization.id}}',
|
||||
ORGANIZATION_NAME: '{{organization.name}}',
|
||||
RANDOM_DIGITS: '{{random.digits}}',
|
||||
} as const;
|
||||
@@ -0,0 +1,52 @@
|
||||
import slugify from '@sindresorhus/slugify';
|
||||
import { createError } from '../../../shared/errors/errors';
|
||||
import { createLogger } from '../../../shared/logger/logger';
|
||||
import { isNil } from '../../../shared/utils';
|
||||
import { parseEmailAddress } from '../../intake-emails.models';
|
||||
import { defineIntakeEmailUsernameDriverFactory } from '../intake-email-username.models';
|
||||
import { PATTERN_INTAKE_EMAIL_ADDRESSES_DRIVER_NAME } from './pattern.intake-email-username-driver.config';
|
||||
import { PATTERNS_PLACEHOLDERS } from './pattern.intake-email-username-driver.constants';
|
||||
|
||||
export const patternIntakeEmailUsernameDriverFactory = defineIntakeEmailUsernameDriverFactory(({
|
||||
logger = createLogger({ namespace: 'intake-emails.addresses-drivers.pattern' }),
|
||||
config,
|
||||
usersRepository,
|
||||
organizationsRepository,
|
||||
}) => {
|
||||
const { pattern } = config.intakeEmails.username.drivers.pattern;
|
||||
|
||||
return {
|
||||
name: PATTERN_INTAKE_EMAIL_ADDRESSES_DRIVER_NAME,
|
||||
generateIntakeEmailUsername: async ({ userId, organizationId }) => {
|
||||
const [{ user }, { organization }] = await Promise.all([
|
||||
usersRepository.getUserById({ userId }),
|
||||
organizationsRepository.getOrganizationById({ organizationId }),
|
||||
]);
|
||||
|
||||
if (isNil(user) || isNil(organization)) {
|
||||
// Should not really happen, there is a check on the routes handlers
|
||||
throw createError({
|
||||
message: 'User or organization not found',
|
||||
code: 'intake-emails.addresses.user_or_organization_not_found',
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const { username: userEmailUsername } = parseEmailAddress({ email: user.email });
|
||||
|
||||
const rawUsername = pattern
|
||||
.replaceAll(PATTERNS_PLACEHOLDERS.USER_NAME, user.name ?? '')
|
||||
.replaceAll(PATTERNS_PLACEHOLDERS.USER_ID, user.id)
|
||||
.replaceAll(PATTERNS_PLACEHOLDERS.USER_EMAIL_USERNAME, userEmailUsername)
|
||||
.replaceAll(PATTERNS_PLACEHOLDERS.ORGANIZATION_ID, organization.id)
|
||||
.replaceAll(PATTERNS_PLACEHOLDERS.ORGANIZATION_NAME, organization.name)
|
||||
.replaceAll(PATTERNS_PLACEHOLDERS.RANDOM_DIGITS, () => Math.floor(Math.random() * 10000).toString());
|
||||
|
||||
const username = slugify(rawUsername);
|
||||
|
||||
logger.debug({ rawUsername, username, pattern, userId, organizationId }, 'Generated email address');
|
||||
|
||||
return { username };
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { generateId as generateHumanReadableId } from '@corentinth/friendly-ids';
|
||||
import { createLogger } from '../../../shared/logger/logger';
|
||||
import { defineIntakeEmailUsernameDriverFactory } from '../intake-email-username.models';
|
||||
|
||||
export const RANDOM_INTAKE_EMAIL_ADDRESSES_DRIVER_NAME = 'random';
|
||||
|
||||
export const randomIntakeEmailUsernameDriverFactory = defineIntakeEmailUsernameDriverFactory(({ logger = createLogger({ namespace: 'intake-emails.addresses-drivers.random' }) }) => {
|
||||
return {
|
||||
name: RANDOM_INTAKE_EMAIL_ADDRESSES_DRIVER_NAME,
|
||||
generateIntakeEmailUsername: async () => {
|
||||
const username = generateHumanReadableId();
|
||||
|
||||
logger.debug({ username }, 'Generated email address');
|
||||
|
||||
return { username };
|
||||
},
|
||||
};
|
||||
});
|
||||
18
pnpm-lock.yaml
generated
18
pnpm-lock.yaml
generated
@@ -292,6 +292,9 @@ importers:
|
||||
'@paralleldrive/cuid2':
|
||||
specifier: ^2.2.2
|
||||
version: 2.2.2
|
||||
'@sindresorhus/slugify':
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0
|
||||
better-auth:
|
||||
specifier: 'catalog:'
|
||||
version: 1.3.4(react-dom@19.0.0(react@18.3.1))(react@18.3.1)
|
||||
@@ -3080,6 +3083,14 @@ packages:
|
||||
resolution: {integrity: sha512-1hsLpRHfSuMB9ee2aAdh0Htza/X3f4djhYISrggqGe3xopNjOcePiSDkDDoPzDYaaMCrbqGP1H2TYU7bgL9PmA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@sindresorhus/slugify@3.0.0':
|
||||
resolution: {integrity: sha512-SCrKh1zS96q+CuH5GumHcyQEVPsM4Ve8oE0E6tw7AAhGq50K8ojbTUOQnX/j9Mhcv/AXiIsbCfquovyGOo5fGw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@sindresorhus/transliterate@2.0.0':
|
||||
resolution: {integrity: sha512-lRx63oCHxeJ90DqIgmbxH1PQmiBDY1wVaLzB4hK0d/xS5BrG1iZO3HdCJS/DQJk6GJ8xHDev8OMI7iGxvE1ZUA==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@smithy/abort-controller@4.0.4':
|
||||
resolution: {integrity: sha512-gJnEjZMvigPDQWHrW3oPrFhQtkrgqBkyjj3pCIdF3A5M6vsZODG93KNlfJprv6bp4245bdT32fsHK4kkH3KYDA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
@@ -11208,6 +11219,13 @@ snapshots:
|
||||
'@peculiar/asn1-schema': 2.3.15
|
||||
'@peculiar/asn1-x509': 2.3.15
|
||||
|
||||
'@sindresorhus/slugify@3.0.0':
|
||||
dependencies:
|
||||
'@sindresorhus/transliterate': 2.0.0
|
||||
escape-string-regexp: 5.0.0
|
||||
|
||||
'@sindresorhus/transliterate@2.0.0': {}
|
||||
|
||||
'@smithy/abort-controller@4.0.4':
|
||||
dependencies:
|
||||
'@smithy/types': 4.3.1
|
||||
|
||||
Reference in New Issue
Block a user