diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 2c044900..00000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Build - -on: - push: - branches: [develop] - pull_request: - branches: [develop] - -jobs: - build: - runs-on: ubuntu-latest - steps: - - name: Check out repository code - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 - with: - node-version: '14.16.0' - cache: 'yarn' - - run: yarn install - - run: yarn build - unit-tests: - runs-on: ubuntu-latest - steps: - - name: Check out repository code - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 - with: - node-version: '14.16.0' - cache: 'yarn' - - run: yarn install - - run: yarn test-ci diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml new file mode 100644 index 00000000..1c371d96 --- /dev/null +++ b/.github/workflows/develop.yml @@ -0,0 +1,49 @@ +name: Build + +on: + push: + branches: [develop-clever] + pull_request: + branches: [develop-clever] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Check out repository code + uses: actions/checkout@v2 + - uses: actions/setup-node@v2 + with: + node-version: '14.16.0' + cache: 'yarn' + - run: yarn install + - run: yarn build + unit-tests: + runs-on: ubuntu-latest + steps: + - name: Check out repository code + uses: actions/checkout@v2 + - uses: actions/setup-node@v2 + with: + node-version: '14.16.0' + cache: 'yarn' + - run: yarn install + - run: yarn test-ci + deployment: + runs-on: ubuntu-latest + needs: [build, unit-tests] + if: "github.event_name == 'push' && !contains(github.event.head_commit.message, '[ci skip]') && !contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.pull_request.title, '[skip ci]') && !contains(github.event.pull_request.title, '[ci skip]')" + timeout-minutes: 40 + env: + NODE_VERSION: 16.17.1 + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - uses: 47ng/actions-clever-cloud@v1.2.0 + with: + appID: ${{ secrets.CLEVER_APP_DEVELOP }} + force: true + env: + CLEVER_TOKEN: ${{ secrets.CLEVER_TOKEN }} + CLEVER_SECRET: ${{ secrets.CLEVER_SECRET }} \ No newline at end of file diff --git a/packages/server/esp/dsc/dscProvider.js b/packages/server/esp/dsc/dscProvider.js index ee0ae04e..e70ba430 100644 --- a/packages/server/esp/dsc/dscProvider.js +++ b/packages/server/esp/dsc/dscProvider.js @@ -19,7 +19,11 @@ class DscProvider { async connectApiCall() { return axios.get(`${config.dscUrl}`, { - headers: { apiKey: this.apiKey, 'Content-Type': 'application/json' }, + headers: { + apiKey: this.apiKey, + 'Content-Type': 'application/json', + 'User-Agent': config.dscUserAgent, + }, }); } @@ -54,6 +58,7 @@ class DscProvider { headers: { apiKey: this.apiKey, 'Content-Type': 'application/json', + 'User-Agent': config.dscUserAgent, }, }); } @@ -62,7 +67,11 @@ class DscProvider { const url = `${config.dscUrl}/withTypeCampagne`; try { return await axios.post(url, restData, { - headers: { apiKey: this.apiKey, 'Content-Type': 'application/json' }, + headers: { + apiKey: this.apiKey, + 'Content-Type': 'application/json', + 'User-Agent': config.dscUserAgent, + }, params: { typeCampagne }, }); } catch (error) { @@ -74,7 +83,11 @@ class DscProvider { const url = `${config.dscUrl}/withTypeCampagne/${campaignMailId}`; try { return await axios.put(url, restData, { - headers: { apiKey: this.apiKey, 'Content-Type': 'application/json' }, + headers: { + apiKey: this.apiKey, + 'Content-Type': 'application/json', + 'User-Agent': config.dscUserAgent, + }, params: { typeCampagne }, }); } catch (error) { diff --git a/packages/server/index.js b/packages/server/index.js index fa72ca11..c8b1fc22 100644 --- a/packages/server/index.js +++ b/packages/server/index.js @@ -15,7 +15,6 @@ const moment = require('moment'); const { Nuxt, Builder } = require('nuxt'); const terminate = require('./utils/terminate.js'); -const cluster = require('cluster'); const config = require('./node.config.js'); const nuxtConfig = require('../../nuxt.config.js'); @@ -33,297 +32,272 @@ const imageRouter = require('./image/image.routes'); const accountRouter = require('./account/account.routes'); const EmailGroupRouter = require('./emails-group/emails-group.routes'); -const workers = - process.env.WORKERS <= require('os').cpus().length ? process.env.WORKERS : 1; - -if (cluster.isMaster) { - logger.log(chalk.cyan('start cluster with %s workers'), workers); - - for (let i = 0; i < workers; ++i) { - const worker = cluster.fork().process; - logger.log(chalk.green('worker %s started.'), worker.pid); - } - - cluster.on('exit', function (worker) { - logger.log( - chalk.bgYellow('worker %s died. restart...'), - worker.process.pid - ); - cluster.fork(); +const app = express(); + +const store = new MongoDBStore({ + uri: config.database, + collection: 'sessions', +}); + +app.use( + session({ + secret: config.sessionSecret, + name: 'badsender.sid', + resave: false, + saveUninitialized: false, + store: store, + }) +); + +mongoose.connect(config.database, { + useNewUrlParser: true, + useUnifiedTopology: true, +}); + +const db = mongoose.connection; +db.on('error', console.error.bind(console, 'connection error:')); +db.once('open', () => { + console.log(chalk.green('DB Connection open')); +}); + +app.use(cookieParser()); +// fix size error while downloading a zip +// https://stackoverflow.com/a/19965089 +app.use(bodyParser.json({ limit: '50mb' })); +app.use(bodyParser.urlencoded({ limit: '50mb', extended: false })); + +// enable gzip compression +// • file manager uploads are omitted by this +// • Events routes are omitted from this due to SSE's response cut +// https://github.com/expressjs/compression#chunksize +const EVENT_ROUTE_REGEX = /\/events$/; +app.use( + compression({ + // • this param was used for compression to play gracefully with SSE + // but it doesn't solve our chunksize issues + // https://github.com/expressjs/compression/issues/86#issuecomment-245377396 + // flush: zlib.Z_SYNC_FLUSH, + filter: (req) => !EVENT_ROUTE_REGEX.test(req.path), + }) +); + +app.use(favicon(path.join(__dirname, '../../packages/ui/static/favicon.png'))); + +// FORCE HTTPS +if (!config.isDev) { + app.use((req, res, next) => { + if (req.header('x-forwarded-proto') !== 'https') + res.redirect(`https://${req.header('host')}${req.url}`); + else next(); }); -} else { - const app = express(); - - const store = new MongoDBStore({ - uri: config.database, - collection: 'sessions', - }); - - app.use( - session({ - secret: config.sessionSecret, - name: 'badsender.sid', - resave: false, - saveUninitialized: false, - store: store, - }) - ); - - mongoose.connect(config.database, { - useNewUrlParser: true, - useUnifiedTopology: true, - }); - - const db = mongoose.connection; - db.on('error', console.error.bind(console, 'connection error:')); - db.once('open', () => { - console.log(chalk.green('DB Connection open')); - }); - - app.use(cookieParser()); - // fix size error while downloading a zip - // https://stackoverflow.com/a/19965089 - app.use(bodyParser.json({ limit: '50mb' })); - app.use(bodyParser.urlencoded({ limit: '50mb', extended: false })); - - // enable gzip compression - // • file manager uploads are omitted by this - // • Events routes are omitted from this due to SSE's response cut - // https://github.com/expressjs/compression#chunksize - const EVENT_ROUTE_REGEX = /\/events$/; - app.use( - compression({ - // • this param was used for compression to play gracefully with SSE - // but it doesn't solve our chunksize issues - // https://github.com/expressjs/compression/issues/86#issuecomment-245377396 - // flush: zlib.Z_SYNC_FLUSH, - filter: (req) => !EVENT_ROUTE_REGEX.test(req.path), - }) - ); - - app.use( - favicon(path.join(__dirname, '../../packages/ui/static/favicon.png')) - ); +} - // FORCE HTTPS - if (!config.isDev) { - app.use((req, res, next) => { - if (req.header('x-forwarded-proto') !== 'https') - res.redirect(`https://${req.header('host')}${req.url}`); - else next(); - }); +// ----- TEMPLATES + +// we need to keep a template engine to render Mosaico +// • we could have done it without one… +// • …but for fast refactor this will do for now + +app.set('views', path.join(__dirname, './html-templates')); +app.set('view engine', 'pug'); + +// ----- STATIC + +const md5public = require('./md5public.json'); +const maxAge = config.isDev + ? moment.duration(30, 'minutes') + : moment.duration(1, 'years'); +const staticOptions = { maxAge: maxAge.as('milliseconds') }; +const compiledStatic = express.static( + path.join(__dirname, '../../public/dist'), + staticOptions +); +const compiledStaticNoCache = express.static( + path.join(__dirname, '../../public/dist') +); +const apiDocumentationNoCache = express.static( + path.join(__dirname, '../documentation/api') +); + +app.locals.md5Url = (url) => { + // disable md5 on dev + // better for hot reload + if (config.isDev) return url; + if (url in md5public) url = `/${md5public[url]}${url}`; + return url; +}; + +const removeHash = (req, res, next) => { + const { md5 } = req.params; + const staticPath = req.url.replace(`/${md5}`, ''); + req._restoreUrl = req.url; + if (md5public[staticPath] === md5) { + req.url = staticPath; + // we don't want statics to be cached by the browser if the md5 is invalid + // pass it to the next static handler which doesn't set cache + } else { + // console.log('[MD5] bad hash for', staticPath, md5) + req._staticPath = staticPath; } - - // ----- TEMPLATES - - // we need to keep a template engine to render Mosaico - // • we could have done it without one… - // • …but for fast refactor this will do for now - - app.set('views', path.join(__dirname, './html-templates')); - app.set('view engine', 'pug'); - - // ----- STATIC - - const md5public = require('./md5public.json'); - const maxAge = config.isDev - ? moment.duration(30, 'minutes') - : moment.duration(1, 'years'); - const staticOptions = { maxAge: maxAge.as('milliseconds') }; - const compiledStatic = express.static( - path.join(__dirname, '../../public/dist'), - staticOptions - ); - const compiledStaticNoCache = express.static( - path.join(__dirname, '../../public/dist') - ); - const apiDocumentationNoCache = express.static( - path.join(__dirname, '../documentation/api') - ); - - app.locals.md5Url = (url) => { - // disable md5 on dev - // better for hot reload - if (config.isDev) return url; - if (url in md5public) url = `/${md5public[url]}${url}`; - return url; - }; - - const removeHash = (req, res, next) => { - const { md5 } = req.params; - const staticPath = req.url.replace(`/${md5}`, ''); - req._restoreUrl = req.url; - if (md5public[staticPath] === md5) { - req.url = staticPath; - // we don't want statics to be cached by the browser if the md5 is invalid - // pass it to the next static handler which doesn't set cache - } else { - // console.log('[MD5] bad hash for', staticPath, md5) - req._staticPath = staticPath; - } - next(); - }; - - const restoreUrl = (req, res, next) => { - // • get here if static middleware fail to find the file - // • even if the md5 is invalid we ca guess that the file exists - if (req._staticPath in md5public) { - // console.log( '[MD5] RESTOREURL – found in md5Public with bad hash', req.url, req._staticPath ) - req.url = req._staticPath; - // • if not that mean we have an url for another ressource => restore the original url - } else { - console.log('[MD5] should be another ressource', req._restoreUrl); - req.url = req._restoreUrl; + next(); +}; + +const restoreUrl = (req, res, next) => { + // • get here if static middleware fail to find the file + // • even if the md5 is invalid we ca guess that the file exists + if (req._staticPath in md5public) { + // console.log( '[MD5] RESTOREURL – found in md5Public with bad hash', req.url, req._staticPath ) + req.url = req._staticPath; + // • if not that mean we have an url for another ressource => restore the original url + } else { + console.log('[MD5] should be another ressource', req._restoreUrl); + req.url = req._restoreUrl; + } + next(); +}; + +// compiled assets +app.get('/:md5([a-zA-Z0-9]{32})*', removeHash, compiledStatic, restoreUrl); +app.use(compiledStaticNoCache); + +// committed assets +app.use(express.static(path.join(__dirname, '../../public'))); +// libs +app.use( + '/lib/skins', + express.static(path.join(__dirname, '../../public/skins')) +); +// API documentation +app.use('/api/documentation', apiDocumentationNoCache); + +// ----- SESSION & I18N + +app.use(logger.logRequest()); +app.use(logger.logResponse()); + +app.use(passport.initialize()); +app.use(passport.session()); + +app.get( + '/account/SAML-login', + (req, res, next) => { + if (!req.body.SAMLResponse && !req.query.email) { + return res.redirect('/'); } next(); - }; - - // compiled assets - app.get('/:md5([a-zA-Z0-9]{32})*', removeHash, compiledStatic, restoreUrl); - app.use(compiledStaticNoCache); - - // committed assets - app.use(express.static(path.join(__dirname, '../../public'))); - // libs - app.use( - '/lib/skins', - express.static(path.join(__dirname, '../../public/skins')) - ); - // API documentation - app.use('/api/documentation', apiDocumentationNoCache); - - // ----- SESSION & I18N - - app.use(logger.logRequest()); - app.use(logger.logResponse()); - - app.use(passport.initialize()); - app.use(passport.session()); - - app.get( - '/account/SAML-login', - (req, res, next) => { - if (!req.body.SAMLResponse && !req.query.email) { - return res.redirect('/'); - } - next(); - }, - passport.authenticate('saml', { failureRedirect: '/', failureFlash: true }), - (err, req, res, _next) => { - console.log({ err }); - if (err) { - return res.redirect('/'); - } + }, + passport.authenticate('saml', { failureRedirect: '/', failureFlash: true }), + (err, req, res, _next) => { + console.log({ err }); + if (err) { return res.redirect('/'); } + return res.redirect('/'); + } +); + +app.post( + '/SAML-login/callback', + bodyParser.urlencoded({ extended: false }), + passport.authenticate('saml', { failureRedirect: '/', failureFlash: true }), + function (req, res) { + res.redirect('/'); + } +); + +app.post( + '/account/login/admin/', + passport.authenticate('local', { + successReturnToOrRedirect: '/groups', + failureRedirect: '/account/login/admin', + failureFlash: true, + successFlash: true, + }) +); + +app.get('/account/logout', (req, res) => { + req.logout(); + res.redirect('/account/login'); +}); + +// Passport configuration +const { GUARD_USER_REDIRECT } = require('./account/auth.guard.js'); + +// API routes +app.use('/api/folders', folderRouter); +app.use('/api/profiles', profileRouter); +app.use('/api/workspaces', workspaceRouter); +app.use('/api/personalized-blocks', personalizedBlockRouter); +app.use('/api/groups', groupRouter); +app.use('/api/mailings', mailingRouter); +app.use('/api/templates', templateRouter); +app.use('/api/users', userRouter); +app.use('/api/images', imageRouter); +app.use('/api/emails-groups', EmailGroupRouter); +app.use('/api/account', accountRouter); +app.use('/api/version', versionRouter); + +// Mosaico's editor route +const mosaicoEditor = require('./mailing/mosaico-editor.controller.js'); +app.get( + '/editor/:mailingId', + GUARD_USER_REDIRECT, + mosaicoEditor.exposeHelpersToPug, + mosaicoEditor.render +); + +const nuxt = new Nuxt(nuxtConfig); +const isNuxtReady = + config.isDev && !config.nuxt.preventBuild + ? new Builder(nuxt).build() + : nuxt.ready(); +isNuxtReady.then(() => logger.info('Nuxt initialized')); + +app.use(nuxt.render); + +app.use(function apiErrorHandler(err, req, res) { + logger.error(err); + // delete err.config; + // anything can come here + // • make sure we have the minimum error informations + const errStatus = err.status || err.statusCode || (err.status = 500); + const errMessage = err.message || 'an error as occurred'; + const stack = err.stack ? err.stack : new Error(err).stack; + const errStack = stack.split('\n').map((line) => line.trim()); + const errorResponse = { + ...err, + status: errStatus, + message: errMessage, + }; + // we don't want the stack to leak in production mode + if (config.isDev) errorResponse.stack = errStack; + res.status(errStatus).json(errorResponse); +}); + +const server = app.listen(config.PORT, () => { + console.log( + chalk.green('API is listening on port'), + chalk.cyan(config.PORT), + chalk.green('on mode'), + chalk.cyan(config.NODE_ENV) ); +}); - app.post( - '/SAML-login/callback', - bodyParser.urlencoded({ extended: false }), - passport.authenticate('saml', { failureRedirect: '/', failureFlash: true }), - function (req, res) { - res.redirect('/'); - } - ); - - app.post( - '/account/login/admin/', - passport.authenticate('local', { - successReturnToOrRedirect: '/groups', - failureRedirect: '/account/login/admin', - failureFlash: true, - successFlash: true, - }) - ); - - app.get('/account/logout', (req, res) => { - req.logout(); - res.redirect('/account/login'); - }); - - // Passport configuration - const { GUARD_USER_REDIRECT } = require('./account/auth.guard.js'); - - // API routes - app.use('/api/folders', folderRouter); - app.use('/api/profiles', profileRouter); - app.use('/api/workspaces', workspaceRouter); - app.use('/api/personalized-blocks', personalizedBlockRouter); - app.use('/api/groups', groupRouter); - app.use('/api/mailings', mailingRouter); - app.use('/api/templates', templateRouter); - app.use('/api/users', userRouter); - app.use('/api/images', imageRouter); - app.use('/api/emails-groups', EmailGroupRouter); - app.use('/api/account', accountRouter); - app.use('/api/version', versionRouter); - - // Mosaico's editor route - const mosaicoEditor = require('./mailing/mosaico-editor.controller.js'); - app.get( - '/editor/:mailingId', - GUARD_USER_REDIRECT, - mosaicoEditor.exposeHelpersToPug, - mosaicoEditor.render - ); - - const maintenanceEditor = require('./maintenance/maintenance.controller.js'); - app.get('/maintenance', maintenanceEditor.render); - - const nuxt = new Nuxt(nuxtConfig); - const isNuxtReady = - config.isDev && !config.nuxt.preventBuild - ? new Builder(nuxt).build() - : nuxt.ready(); - isNuxtReady.then(() => logger.info('Nuxt initialized')); - - app.use(nuxt.render); - - app.use(function apiErrorHandler(err, req, res) { - logger.error(err); - // delete err.config; - // anything can come here - // • make sure we have the minimum error informations - const errStatus = err.status || err.statusCode || (err.status = 500); - const errMessage = err.message || 'an error as occurred'; - const stack = err.stack ? err.stack : new Error(err).stack; - const errStack = stack.split('\n').map((line) => line.trim()); - const errorResponse = { - ...err, - status: errStatus, - message: errMessage, - }; - // we don't want the stack to leak in production mode - if (config.isDev) errorResponse.stack = errStack; - res.status(errStatus).json(errorResponse); - }); - - const server = app.listen(config.PORT, () => { - console.log( - chalk.green('API is listening on port'), - chalk.cyan(config.PORT), - chalk.green('on mode'), - chalk.cyan(config.NODE_ENV) - ); - }); - - const exitHandler = terminate(server, { - coredump: false, - timeout: 500, - }); - // +const exitHandler = terminate(server, { + coredump: false, + timeout: 500, +}); +// - process.on('SIGTERM', exitHandler(0, 'SIGTERM')); - process.on('SIGINT', exitHandler(0, 'SIGINT')); +process.on('SIGTERM', exitHandler(0, 'SIGTERM')); +process.on('SIGINT', exitHandler(0, 'SIGINT')); - process.on('uncaughtException', exitHandler(1, 'Uncaught exception')); +process.on('uncaughtException', exitHandler(1, 'Uncaught exception')); - process.on('unhandledRejection', exitHandler(1, 'Unhandled promise')); +process.on('unhandledRejection', exitHandler(1, 'Unhandled promise')); - process.stdout.on('error', function (err) { - if (err.code === 'EPIPE') { - exitHandler(1, 'EPIPE Exception'); - } - }); -} +process.stdout.on('error', function (err) { + if (err.code === 'EPIPE') { + exitHandler(1, 'EPIPE Exception'); + } +}); diff --git a/packages/server/mailing/mailing.schema.js b/packages/server/mailing/mailing.schema.js index 8199eef4..cfe357de 100644 --- a/packages/server/mailing/mailing.schema.js +++ b/packages/server/mailing/mailing.schema.js @@ -246,9 +246,6 @@ MailingSchema.statics.findForApiWithPagination = async function findForApiWithPa author: 1, userId: '$_user', tags: 1, - hasHtmlPreview: { - $cond: { if: { $gt: ['$previewHtml', null] }, then: true, else: false }, - }, _workspace: 1, espIds: 1, updatedAt: 1, @@ -260,7 +257,20 @@ MailingSchema.statics.findForApiWithPagination = async function findForApiWithPa const { docs, ...restPaginationProperties } = result; - const convertedResultMailingDocs = docs?.map( + const ids = docs.map((doc) => doc._id); + const mailingsWithHtmlPreview = await this.find( + { _id: { $in: ids }, previewHtml: { $exists: true } }, + { _id: 1 } + ).lean(); + const mailingsWithHtmlPreviewSet = new Set( + mailingsWithHtmlPreview.map((mailing) => mailing._id.toString()) + ); + const finalDocs = docs.map((doc) => ({ + ...doc, + hasHtmlPreview: mailingsWithHtmlPreviewSet.has(doc._id.toString()), + })); + + const convertedResultMailingDocs = finalDocs.map( ({ wireframe, author, ...doc }) => ({ templateName: wireframe, userName: author, diff --git a/packages/server/node.config.js b/packages/server/node.config.js index f22e760c..45c05c4a 100644 --- a/packages/server/node.config.js +++ b/packages/server/node.config.js @@ -61,6 +61,7 @@ const config = rc('lepatron', { }, proxyUrl: process.env.QUOTAGUARDSTATIC_URL, dscUrl: process.env.DSC_ESP_URL, + dscUserAgent: process.env.DSC_USER_AGENT, NODE_ENV: process.env.NODE_ENV, }); diff --git a/packages/server/template/template.schema.js b/packages/server/template/template.schema.js index 99a98012..c73cab41 100644 --- a/packages/server/template/template.schema.js +++ b/packages/server/template/template.schema.js @@ -110,19 +110,28 @@ TemplateSchema.statics.findForApi = async function findForApi(query = {}) { updatedAt: 1, _company: 1, assets: 1, - hasMarkup: { - $cond: { if: { $gt: ['$markup', null] }, then: true, else: false }, - }, }) .populate({ path: '_company', select: 'id name' }) .sort({ name: 1 }) .lean(); + // Second query to check existence of 'markup' for each template + const ids = templates.map((template) => template._id); + // find markup exist or not without charging the markup + const templatesWithMarkup = await this.find( + { _id: { $in: ids }, markup: { $exists: true } }, + { _id: 1 } + ).lean(); + + const templatesWithMarkupSet = new Set( + templatesWithMarkup.map((t) => t._id.toString()) + ); const finalTemplates = templates.map(({ assets, ...template }) => ({ ...template, + id: template._id, + hasMarkup: templatesWithMarkupSet.has(template._id.toString()), coverImage: JSON.parse(assets)?.['_full.png'] || null, })); return finalTemplates; }; - module.exports = TemplateSchema; diff --git a/packages/server/utils/storage-s3.js b/packages/server/utils/storage-s3.js index 5088bc32..3a2c49d2 100644 --- a/packages/server/utils/storage-s3.js +++ b/packages/server/utils/storage-s3.js @@ -13,7 +13,8 @@ if (!config.isAws) { module.exports = {}; } else { AWS.config.update(config.storage.aws); - const s3 = new AWS.S3(); + const endpoint = new AWS.Endpoint(config.storage.aws.endpoint); + const s3 = new AWS.S3({ endpoint }); // http://docs.aws.amazon.com/AWSJavaScriptSDK/guide/node-examples.html#Amazon_Simple_Storage_Service__Amazon_S3_ // http://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#getObject-property diff --git a/packages/ui/routes/mailings/__partials/mailings-new-modal.vue b/packages/ui/routes/mailings/__partials/mailings-new-modal.vue index e0ec8617..0b022135 100644 --- a/packages/ui/routes/mailings/__partials/mailings-new-modal.vue +++ b/packages/ui/routes/mailings/__partials/mailings-new-modal.vue @@ -134,7 +134,7 @@ export default {