feat(core): Switch to MJML for email templates (#10518)
This commit is contained in:
committed by
GitHub
parent
9e1dac0465
commit
dbc10fe9f5
@@ -78,6 +78,7 @@
|
||||
"chokidar": "^3.5.2",
|
||||
"concurrently": "^8.2.0",
|
||||
"ioredis-mock": "^8.8.1",
|
||||
"mjml": "^4.15.3",
|
||||
"ts-essentials": "^7.0.3"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { writeFileSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import shell from 'shelljs';
|
||||
import { rawTimeZones } from '@vvo/tzdb';
|
||||
import glob from 'fast-glob';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
@@ -13,7 +14,7 @@ const SPEC_THEME_FILENAME = 'swagger-theme.css';
|
||||
|
||||
const publicApiEnabled = process.env.N8N_PUBLIC_API_DISABLED !== 'true';
|
||||
|
||||
copyUserManagementEmailTemplates();
|
||||
generateUserManagementEmailTemplates();
|
||||
generateTimezoneData();
|
||||
|
||||
if (publicApiEnabled) {
|
||||
@@ -21,13 +22,22 @@ if (publicApiEnabled) {
|
||||
bundleOpenApiSpecs();
|
||||
}
|
||||
|
||||
function copyUserManagementEmailTemplates() {
|
||||
const templates = {
|
||||
source: path.resolve(ROOT_DIR, 'src', 'user-management', 'email', 'templates'),
|
||||
destination: path.resolve(ROOT_DIR, 'dist', 'user-management', 'email'),
|
||||
};
|
||||
function generateUserManagementEmailTemplates() {
|
||||
const sourceDir = path.resolve(ROOT_DIR, 'src', 'user-management', 'email', 'templates');
|
||||
const destinationDir = path.resolve(ROOT_DIR, 'dist', 'user-management', 'email', 'templates');
|
||||
|
||||
shell.cp('-r', templates.source, templates.destination);
|
||||
shell.mkdir('-p', destinationDir);
|
||||
|
||||
const templates = glob.sync('*.mjml', { cwd: sourceDir });
|
||||
templates.forEach((template) => {
|
||||
if (template.startsWith('_')) return;
|
||||
const source = path.resolve(sourceDir, template);
|
||||
const destination = path.resolve(destinationDir, template.replace(/\.mjml$/, '.handlebars'));
|
||||
const command = `pnpm mjml --output ${destination} ${source}`;
|
||||
shell.exec(command, { silent: false });
|
||||
});
|
||||
|
||||
shell.cp(path.resolve(sourceDir, 'n8n-logo.png'), destinationDir);
|
||||
}
|
||||
|
||||
function copySwaggerTheme() {
|
||||
|
||||
@@ -13,7 +13,6 @@ import { RESPONSE_ERROR_MESSAGES } from '@/constants';
|
||||
import { MfaService } from '@/mfa/mfa.service';
|
||||
import { Logger } from '@/logger';
|
||||
import { ExternalHooks } from '@/external-hooks';
|
||||
import { UrlService } from '@/services/url.service';
|
||||
import { InternalServerError } from '@/errors/response-errors/internal-server.error';
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||
@@ -31,7 +30,6 @@ export class PasswordResetController {
|
||||
private readonly authService: AuthService,
|
||||
private readonly userService: UserService,
|
||||
private readonly mfaService: MfaService,
|
||||
private readonly urlService: UrlService,
|
||||
private readonly license: License,
|
||||
private readonly passwordUtility: PasswordUtility,
|
||||
private readonly userRepository: UserRepository,
|
||||
@@ -108,14 +106,12 @@ export class PasswordResetController {
|
||||
|
||||
const url = this.authService.generatePasswordResetUrl(user);
|
||||
|
||||
const { id, firstName, lastName } = user;
|
||||
const { id, firstName } = user;
|
||||
try {
|
||||
await this.mailer.passwordReset({
|
||||
email,
|
||||
firstName,
|
||||
lastName,
|
||||
passwordResetUrl: url,
|
||||
domain: this.urlService.getInstanceBaseUrl(),
|
||||
});
|
||||
} catch (error) {
|
||||
this.eventService.emit('email-failed', {
|
||||
|
||||
@@ -138,7 +138,6 @@ export class UserService {
|
||||
const result = await this.mailer.invite({
|
||||
email,
|
||||
inviteAcceptUrl,
|
||||
domain,
|
||||
});
|
||||
if (result.emailSent) {
|
||||
invitedUser.user.emailSent = true;
|
||||
@@ -168,7 +167,6 @@ export class UserService {
|
||||
this.logger.error('Failed to send email', {
|
||||
userId: owner.id,
|
||||
inviteAcceptUrl,
|
||||
domain,
|
||||
email,
|
||||
});
|
||||
invitedUser.error = e.message;
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
export type InviteEmailData = {
|
||||
email: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
inviteAcceptUrl: string;
|
||||
domain: string;
|
||||
};
|
||||
|
||||
export type PasswordResetData = {
|
||||
email: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
firstName: string;
|
||||
passwordResetUrl: string;
|
||||
domain: string;
|
||||
};
|
||||
|
||||
export type SendEmailResult = {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { GlobalConfig } from '@n8n/config';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
|
||||
import type { UrlService } from '@/services/url.service';
|
||||
import type { InviteEmailData, PasswordResetData } from '@/user-management/email/Interfaces';
|
||||
import { NodeMailer } from '@/user-management/email/node-mailer';
|
||||
import { UserManagementMailer } from '@/user-management/email/user-management-mailer';
|
||||
@@ -31,7 +32,7 @@ describe('UserManagementMailer', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
const userManagementMailer = new UserManagementMailer(config, mock(), mock(), mock());
|
||||
const userManagementMailer = new UserManagementMailer(config, mock(), mock(), mock(), mock());
|
||||
|
||||
it('should not setup email transport', async () => {
|
||||
expect(userManagementMailer.isEmailSetUp).toBe(false);
|
||||
@@ -56,7 +57,18 @@ describe('UserManagementMailer', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
const userManagementMailer = new UserManagementMailer(config, mock(), mock(), mock());
|
||||
const urlService = mock<UrlService>();
|
||||
const userManagementMailer = new UserManagementMailer(
|
||||
config,
|
||||
mock(),
|
||||
mock(),
|
||||
urlService,
|
||||
mock(),
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
urlService.getInstanceBaseUrl.mockReturnValue('https://n8n.url');
|
||||
});
|
||||
|
||||
it('should setup email transport', async () => {
|
||||
expect(userManagementMailer.isEmailSetUp).toBe(true);
|
||||
@@ -67,9 +79,7 @@ describe('UserManagementMailer', () => {
|
||||
const result = await userManagementMailer.invite(inviteEmailData);
|
||||
expect(result.emailSent).toBe(true);
|
||||
expect(nodeMailer.sendMail).toHaveBeenCalledWith({
|
||||
body: expect.stringContaining(
|
||||
`<a href="${inviteEmailData.inviteAcceptUrl}" target="_blank">`,
|
||||
),
|
||||
body: expect.stringContaining(`href="${inviteEmailData.inviteAcceptUrl}"`),
|
||||
emailRecipients: email,
|
||||
subject: 'You have been invited to n8n',
|
||||
});
|
||||
@@ -79,7 +89,7 @@ describe('UserManagementMailer', () => {
|
||||
const result = await userManagementMailer.passwordReset(passwordResetData);
|
||||
expect(result.emailSent).toBe(true);
|
||||
expect(nodeMailer.sendMail).toHaveBeenCalledWith({
|
||||
body: expect.stringContaining(`<a href="${passwordResetData.passwordResetUrl}">`),
|
||||
body: expect.stringContaining(`href="${passwordResetData.passwordResetUrl}"`),
|
||||
emailRecipients: email,
|
||||
subject: 'n8n password reset',
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Service } from 'typedi';
|
||||
import path from 'node:path';
|
||||
import { pick } from 'lodash';
|
||||
import type { Transporter } from 'nodemailer';
|
||||
import { createTransport } from 'nodemailer';
|
||||
@@ -45,12 +46,20 @@ export class NodeMailer {
|
||||
|
||||
async sendMail(mailData: MailData): Promise<SendEmailResult> {
|
||||
try {
|
||||
await this.transport?.sendMail({
|
||||
await this.transport.sendMail({
|
||||
from: this.sender,
|
||||
to: mailData.emailRecipients,
|
||||
subject: mailData.subject,
|
||||
text: mailData.textOnly,
|
||||
html: mailData.body,
|
||||
attachments: [
|
||||
{
|
||||
cid: 'n8n-logo',
|
||||
filename: 'n8n-logo.png',
|
||||
path: path.resolve(__dirname, 'templates/n8n-logo.png'),
|
||||
contentDisposition: 'inline',
|
||||
},
|
||||
],
|
||||
});
|
||||
this.logger.debug(
|
||||
`Email sent successfully to the following recipients: ${mailData.emailRecipients.toString()}`,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<mj-head>
|
||||
<mj-attributes>
|
||||
<mj-all font-family="Open Sans, sans-serif"></mj-all>
|
||||
<mj-body background-color="#fbfcfe"></mj-body>
|
||||
<mj-text
|
||||
font-weight="400"
|
||||
font-size="16px"
|
||||
color="#444444"
|
||||
line-height="24px"
|
||||
padding="10px 0 0 0"
|
||||
align="center"
|
||||
></mj-text>
|
||||
<mj-button
|
||||
background-color="#ff6f5c"
|
||||
color="#ffffff"
|
||||
font-size="18px"
|
||||
font-weight="600"
|
||||
align="center"
|
||||
padding-top="20px"
|
||||
line-height="24px"
|
||||
border-radius="4px"
|
||||
></mj-button>
|
||||
<mj-section padding="20px 0px"></mj-section>
|
||||
</mj-attributes>
|
||||
</mj-head>
|
||||
@@ -0,0 +1,5 @@
|
||||
<mj-section>
|
||||
<mj-column>
|
||||
<mj-image src="cid:n8n-logo" height="40px" width="70px" />
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
@@ -0,0 +1,18 @@
|
||||
<mjml>
|
||||
<mj-include path="./_common.mjml" />
|
||||
<mj-body>
|
||||
<mj-section>
|
||||
<mj-column>
|
||||
<mj-text font-size="24px" color="#ff6f5c">A credential has been shared with you</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section background-color="#FFFFFF" border="1px solid #ddd">
|
||||
<mj-column>
|
||||
<mj-text><b>"{{ credentialsName }}"</b> credential has been shared with you.</mj-text>
|
||||
<mj-text>To access it, please click the button below.</mj-text>
|
||||
<mj-button href="{{credentialsListUrl}}">Open credential</mj-button>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-include path="./_logo.mjml" />
|
||||
</mj-body>
|
||||
</mjml>
|
||||
@@ -1,4 +0,0 @@
|
||||
<p>Hi there,</p>
|
||||
<p><b>"{{ credentialsName }}" credential</b> has been shared with you.</p>
|
||||
<p>To view all the credentials you have access to within n8n, click the following link:</p>
|
||||
<p><a href="{{ credentialsListUrl }}" target="_blank">{{ credentialsListUrl }}</a></p>
|
||||
@@ -1,5 +0,0 @@
|
||||
<h1>Hi there!</h1>
|
||||
<p>Welcome to n8n, {{firstName}} {{lastName}}</p>
|
||||
<p>Your instance is set up!</p>
|
||||
<p>Use your email to login: {{email}} and the chosen password.</p>
|
||||
<p>Have fun automating!</p>
|
||||
@@ -1,4 +0,0 @@
|
||||
<p>Hi there,</p>
|
||||
<p>You have been invited to join n8n ({{ domain }}).</p>
|
||||
<p>To accept, click the following link:</p>
|
||||
<p><a href="{{ inviteAcceptUrl }}" target="_blank">{{ inviteAcceptUrl }}</a></p>
|
||||
BIN
packages/cli/src/user-management/email/templates/n8n-logo.png
Normal file
BIN
packages/cli/src/user-management/email/templates/n8n-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,31 @@
|
||||
<mjml>
|
||||
<mj-include path="./_common.mjml" />
|
||||
<mj-body>
|
||||
<mj-section>
|
||||
<mj-column>
|
||||
<mj-text font-size="24px" color="#ff6f5c">Reset your n8n password</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section background-color="#FFFFFF" border="1px solid #ddd">
|
||||
<mj-column>
|
||||
<mj-text font-size="20px">Hi {{firstName}},</mj-text>
|
||||
<mj-text>Somebody asked to reset your password on n8n at <b>{{domain}}</b> .</mj-text>
|
||||
<mj-text> Click the following link to choose a new password. </mj-text>
|
||||
<mj-button href="{{passwordResetUrl}}">Set a new password</mj-button>
|
||||
|
||||
<mj-text font-size="14px">
|
||||
The link is only valid for 20 minutes since this email was sent.
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section>
|
||||
<mj-column>
|
||||
<mj-text font-size="12px" color="#777">
|
||||
If you did not request this email, you can safely ignore this. <br />
|
||||
Your password will not be changed.
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-include path="./_logo.mjml" />
|
||||
</mj-body>
|
||||
</mjml>
|
||||
@@ -1,5 +0,0 @@
|
||||
<p>Hi {{firstName}},</p>
|
||||
<p>Somebody asked to reset your password on n8n ({{ domain }}).</p>
|
||||
<br />
|
||||
<p>Click the following link to choose a new password. The link is valid for 20 minutes.</p>
|
||||
<a href="{{ passwordResetUrl }}">{{ passwordResetUrl }}</a>
|
||||
@@ -0,0 +1,18 @@
|
||||
<mjml>
|
||||
<mj-include path="./_common.mjml" />
|
||||
<mj-body>
|
||||
<mj-section>
|
||||
<mj-column>
|
||||
<mj-text font-size="24px" color="#ff6f5c">Welcome to n8n! 🎉</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section background-color="#FFFFFF" border="1px solid #ddd">
|
||||
<mj-column>
|
||||
<mj-text>You have been invited to join n8n at <b>{{domain}}</b> .</mj-text>
|
||||
<mj-text>To accept, please click the button below.</mj-text>
|
||||
<mj-button href="{{inviteAcceptUrl}}">Set up your n8n account</mj-button>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-include path="./_logo.mjml" />
|
||||
</mj-body>
|
||||
</mjml>
|
||||
@@ -0,0 +1,18 @@
|
||||
<mjml>
|
||||
<mj-include path="./_common.mjml" />
|
||||
<mj-body>
|
||||
<mj-section>
|
||||
<mj-column>
|
||||
<mj-text font-size="24px" color="#ff6f5c">A workflow has been shared with you</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section background-color="#FFFFFF" border="1px solid #ddd">
|
||||
<mj-column>
|
||||
<mj-text><b>"{{ workflowName }}"</b> workflow has been shared with you.</mj-text>
|
||||
<mj-text>To access it, please click the button below.</mj-text>
|
||||
<mj-button href="{{workflowUrl}}">Open Workflow</mj-button>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-include path="./_logo.mjml" />
|
||||
</mj-body>
|
||||
</mjml>
|
||||
@@ -1,4 +0,0 @@
|
||||
<p>Hi there,</p>
|
||||
<p><b>"{{ workflowName }}" workflow</b> has been shared with you.</p>
|
||||
<p>To access the workflow, click the following link:</p>
|
||||
<p><a href="{{ workflowUrl }}" target="_blank">{{ workflowUrl }}</a></p>
|
||||
@@ -8,6 +8,7 @@ import { GlobalConfig } from '@n8n/config';
|
||||
import type { User } from '@/databases/entities/User';
|
||||
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
|
||||
import { UserRepository } from '@/databases/repositories/user.repository';
|
||||
import { EventService } from '@/events/event.service';
|
||||
import { Logger } from '@/logger';
|
||||
import { UrlService } from '@/services/url.service';
|
||||
import { InternalServerError } from '@/errors/response-errors/internal-server.error';
|
||||
@@ -15,10 +16,14 @@ import { toError } from '@/utils';
|
||||
|
||||
import type { InviteEmailData, PasswordResetData, SendEmailResult } from './Interfaces';
|
||||
import { NodeMailer } from './node-mailer';
|
||||
import { EventService } from '@/events/event.service';
|
||||
import { inTest } from '@/constants';
|
||||
|
||||
type Template = HandlebarsTemplateDelegate<unknown>;
|
||||
type TemplateName = 'invite' | 'passwordReset' | 'workflowShared' | 'credentialsShared';
|
||||
type TemplateName =
|
||||
| 'user-invited'
|
||||
| 'password-reset-requested'
|
||||
| 'workflow-shared'
|
||||
| 'credentials-shared';
|
||||
|
||||
@Service()
|
||||
export class UserManagementMailer {
|
||||
@@ -35,6 +40,7 @@ export class UserManagementMailer {
|
||||
private readonly logger: Logger,
|
||||
private readonly userRepository: UserRepository,
|
||||
private readonly urlService: UrlService,
|
||||
private readonly eventService: EventService,
|
||||
) {
|
||||
const emailsConfig = globalConfig.userManagement.emails;
|
||||
this.isEmailSetUp = emailsConfig.mode === 'smtp' && emailsConfig.smtp.host !== '';
|
||||
@@ -49,31 +55,23 @@ export class UserManagementMailer {
|
||||
async invite(inviteEmailData: InviteEmailData): Promise<SendEmailResult> {
|
||||
if (!this.mailer) return { emailSent: false };
|
||||
|
||||
const template = await this.getTemplate('invite');
|
||||
const result = await this.mailer.sendMail({
|
||||
const template = await this.getTemplate('user-invited');
|
||||
return await this.mailer.sendMail({
|
||||
emailRecipients: inviteEmailData.email,
|
||||
subject: 'You have been invited to n8n',
|
||||
body: template(inviteEmailData),
|
||||
body: template({ ...this.basePayload, ...inviteEmailData }),
|
||||
});
|
||||
|
||||
// If mailer does not exist it means mail has been disabled.
|
||||
// No error, just say no email was sent.
|
||||
return result ?? { emailSent: false };
|
||||
}
|
||||
|
||||
async passwordReset(passwordResetData: PasswordResetData): Promise<SendEmailResult> {
|
||||
if (!this.mailer) return { emailSent: false };
|
||||
|
||||
const template = await this.getTemplate('passwordReset', 'passwordReset.html');
|
||||
const result = await this.mailer.sendMail({
|
||||
const template = await this.getTemplate('password-reset-requested');
|
||||
return await this.mailer.sendMail({
|
||||
emailRecipients: passwordResetData.email,
|
||||
subject: 'n8n password reset',
|
||||
body: template(passwordResetData),
|
||||
body: template({ ...this.basePayload, ...passwordResetData }),
|
||||
});
|
||||
|
||||
// If mailer does not exist it means mail has been disabled.
|
||||
// No error, just say no email was sent.
|
||||
return result ?? { emailSent: false };
|
||||
}
|
||||
|
||||
async notifyWorkflowShared({
|
||||
@@ -93,7 +91,7 @@ export class UserManagementMailer {
|
||||
|
||||
const emailRecipients = recipients.map(({ email }) => email);
|
||||
|
||||
const populateTemplate = await this.getTemplate('workflowShared', 'workflowShared.html');
|
||||
const populateTemplate = await this.getTemplate('workflow-shared');
|
||||
|
||||
const baseUrl = this.urlService.getInstanceBaseUrl();
|
||||
|
||||
@@ -111,7 +109,7 @@ export class UserManagementMailer {
|
||||
|
||||
this.logger.info('Sent workflow shared email successfully', { sharerId: sharer.id });
|
||||
|
||||
Container.get(EventService).emit('user-transactional-email-sent', {
|
||||
this.eventService.emit('user-transactional-email-sent', {
|
||||
userId: sharer.id,
|
||||
messageType: 'Workflow shared',
|
||||
publicApi: false,
|
||||
@@ -119,7 +117,7 @@ export class UserManagementMailer {
|
||||
|
||||
return result;
|
||||
} catch (e) {
|
||||
Container.get(EventService).emit('email-failed', {
|
||||
this.eventService.emit('email-failed', {
|
||||
user: sharer,
|
||||
messageType: 'Workflow shared',
|
||||
publicApi: false,
|
||||
@@ -148,7 +146,7 @@ export class UserManagementMailer {
|
||||
|
||||
const emailRecipients = recipients.map(({ email }) => email);
|
||||
|
||||
const populateTemplate = await this.getTemplate('credentialsShared', 'credentialsShared.html');
|
||||
const populateTemplate = await this.getTemplate('credentials-shared');
|
||||
|
||||
const baseUrl = this.urlService.getInstanceBaseUrl();
|
||||
|
||||
@@ -166,7 +164,7 @@ export class UserManagementMailer {
|
||||
|
||||
this.logger.info('Sent credentials shared email successfully', { sharerId: sharer.id });
|
||||
|
||||
Container.get(EventService).emit('user-transactional-email-sent', {
|
||||
this.eventService.emit('user-transactional-email-sent', {
|
||||
userId: sharer.id,
|
||||
messageType: 'Credentials shared',
|
||||
publicApi: false,
|
||||
@@ -174,7 +172,7 @@ export class UserManagementMailer {
|
||||
|
||||
return result;
|
||||
} catch (e) {
|
||||
Container.get(EventService).emit('email-failed', {
|
||||
this.eventService.emit('email-failed', {
|
||||
user: sharer,
|
||||
messageType: 'Credentials shared',
|
||||
publicApi: false,
|
||||
@@ -186,21 +184,25 @@ export class UserManagementMailer {
|
||||
}
|
||||
}
|
||||
|
||||
async getTemplate(
|
||||
templateName: TemplateName,
|
||||
defaultFilename = `${templateName}.html`,
|
||||
): Promise<Template> {
|
||||
async getTemplate(templateName: TemplateName): Promise<Template> {
|
||||
let template = this.templatesCache[templateName];
|
||||
if (!template) {
|
||||
const fileExtension = inTest ? 'mjml' : 'handlebars';
|
||||
const templateOverride = this.templateOverrides[templateName];
|
||||
const templatePath =
|
||||
templateOverride && existsSync(templateOverride)
|
||||
? templateOverride
|
||||
: pathJoin(__dirname, `templates/${defaultFilename}`);
|
||||
: pathJoin(__dirname, `templates/${templateName}.${fileExtension}`);
|
||||
const markup = await readFile(templatePath, 'utf-8');
|
||||
template = Handlebars.compile(markup);
|
||||
this.templatesCache[templateName] = template;
|
||||
}
|
||||
return template;
|
||||
}
|
||||
|
||||
private get basePayload() {
|
||||
const baseUrl = this.urlService.getInstanceBaseUrl();
|
||||
const domain = new URL(baseUrl).hostname;
|
||||
return { baseUrl, domain };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user