diff --git a/.eslintignore b/.eslintignore index 2865a05e..806070df 100644 --- a/.eslintignore +++ b/.eslintignore @@ -5,7 +5,9 @@ dist/ *.min.* # Per packages overrides -packages/editor/ - +packages/editor/* +public/lang/* packages/documentation/.vuepress/dist/ packages/documentation/api/ + +!packages/editor/src/js/vue/* \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..e6151805 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +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/.gitignore b/.gitignore index 20b84a93..94b63c2c 100644 --- a/.gitignore +++ b/.gitignore @@ -130,7 +130,7 @@ dist # TernJS port file .tern-port - +.vscode/** # Stores VSCode versions used for testing VSCode extensions .vscode-test diff --git a/.prettierignore b/.prettierignore index 2865a05e..41dde862 100644 --- a/.prettierignore +++ b/.prettierignore @@ -9,3 +9,4 @@ packages/editor/ packages/documentation/.vuepress/dist/ packages/documentation/api/ +packages/ui/helpers/locales diff --git a/jest.config.js b/jest.config.js index 81cc1c40..d5248edf 100644 --- a/jest.config.js +++ b/jest.config.js @@ -114,7 +114,7 @@ module.exports = { // restoreMocks: false, // The root directory that Jest should scan for tests and modules within - // rootDir: undefined, + rootDir: './packages', // A list of paths to directories that Jest should use to search for files in // roots: [ diff --git a/package.json b/package.json index 90377a2f..645616c6 100644 --- a/package.json +++ b/package.json @@ -109,6 +109,7 @@ "stream-buffers": "3.0.2", "validator": "10.11.0", "vue-i18n": "8.15.3", + "vue-select": "3.13.0", "vuelidate": "0.7.4", "xml2json": "0.12.0" }, diff --git a/packages/editor/src/css/badesender-image-gallery.less b/packages/editor/src/css/badesender-image-gallery.less index d3f539a0..c10129dc 100644 --- a/packages/editor/src/css/badesender-image-gallery.less +++ b/packages/editor/src/css/badesender-image-gallery.less @@ -176,7 +176,9 @@ padding: @dialog-margin; display: flex; justify-content: center; - + .ui-tabs-nav { + background: var(--v-secondary-base) !important; + } .tabs_horizontal { background: white; width: 100%; @@ -185,6 +187,7 @@ } .close { visibility: hidden; + color: #fff; cursor: pointer; width: 24px; display: block; diff --git a/packages/editor/src/css/badsender-editor.less b/packages/editor/src/css/badsender-editor.less index f95aef2d..bda891a3 100644 --- a/packages/editor/src/css/badsender-editor.less +++ b/packages/editor/src/css/badsender-editor.less @@ -1,5 +1,4 @@ // this the equivalent of app_standalone_material.less - @import '../../../../node_modules/font-awesome/less/font-awesome.less'; @import 'style_mosaico.less'; @@ -8,6 +7,9 @@ @import (less) '../../../../node_modules/toastr/toastr.less'; +@import (less) '../../../../node_modules/vue-select/dist/vue-select.css'; +@import (less) 'custom-select-style.less'; + // Override variables @tab-text-color: #555; diff --git a/packages/editor/src/css/custom-select-style.less b/packages/editor/src/css/custom-select-style.less new file mode 100644 index 00000000..0595dde6 --- /dev/null +++ b/packages/editor/src/css/custom-select-style.less @@ -0,0 +1,13 @@ +.vs__dropdown-toggle, +.vs__dropdown-menu { + border: none !important; +} + +.vs__selected { + position: absolute; + top: 12px; +} + +.vs__actions > button { + padding-right: 10px; +} \ No newline at end of file diff --git a/packages/editor/src/js/ext/badsender-server-storage.js b/packages/editor/src/js/ext/badsender-server-storage.js index 1fb4a5c7..d61659b0 100644 --- a/packages/editor/src/js/ext/badsender-server-storage.js +++ b/packages/editor/src/js/ext/badsender-server-storage.js @@ -26,6 +26,7 @@ function loader(opts) { name: 'Save', // l10n happens in the template enabled: ko.observable(true), }; + saveCmd.execute = function () { saveCmd.enabled(false); var data = getData(viewModel); @@ -49,7 +50,6 @@ function loader(opts) { // use callback for easier jQuery updates // => Deprecation notice for .success(), .error(), and .complete() function onPostSuccess(data, textStatus, jqXHR) { - console.log('save success'); viewModel.notifier.success(viewModel.t('save-message-success')); } @@ -77,54 +77,7 @@ function loader(opts) { enabled: ko.observable(true), }; testCmd.execute = function () { - console.info('TEST'); - console.log(viewModel.metadata.url.send); - testCmd.enabled(false); - var email = viewModel.t('Insert here the recipient email address'); - email = global.prompt(viewModel.t('Test email address'), email); - - // Don't validate `null` values => isEmail will error - if (!email) return testCmd.enabled(true); - - const emails = email.split(";"); - for (const address of emails){ - if (!isEmail(address)) { - global.alert(viewModel.t('Invalid email address')); - return testCmd.enabled(true); - } - } - - console.log('TODO testing...', email); - var metadata = ko.toJS(viewModel.metadata); - var datas = { - rcpt: email, - html: viewModel.exportHTML(), - }; - $.ajax({ - url: viewModel.metadata.url.send, - method: 'POST', - data: datas, - success: onTestSuccess, - error: onTestError, - complete: onTestComplete, - }); - - function onTestSuccess(data, textStatus, jqXHR) { - console.log('test success'); - viewModel.notifier.success(viewModel.t('Test email sent...')); - } - - function onTestError(jqXHR, textStatus, errorThrown) { - console.log('test error'); - console.log(errorThrown); - viewModel.notifier.error( - viewModel.t('Unexpected error talking to server: contact us!') - ); - } - - function onTestComplete() { - testCmd.enabled(true); - } + viewModel.openTestModal(true); }; ////// diff --git a/packages/editor/src/js/viewmodel.js b/packages/editor/src/js/viewmodel.js index ebcb391e..e8dc180e 100644 --- a/packages/editor/src/js/viewmodel.js +++ b/packages/editor/src/js/viewmodel.js @@ -43,6 +43,7 @@ function initializeEditor(content, blockDefs, thumbPathConverter, galleryUrl) { debug: ko.observable(false), contentListeners: ko.observable(0), loadedTemplate: ko.observable(false), + openTestModal: ko.observable(false), logoPath: 'rs/img/mosaico32.png', logoUrl: '.', logoAlt: 'mosaico', diff --git a/packages/editor/src/js/vue/components/esp/esp-send-mail.js b/packages/editor/src/js/vue/components/esp/esp-send-mail.js index 81f96d54..8312dc5f 100644 --- a/packages/editor/src/js/vue/components/esp/esp-send-mail.js +++ b/packages/editor/src/js/vue/components/esp/esp-send-mail.js @@ -1,78 +1,47 @@ -var Vue = require('vue/dist/vue.common'); -var { SendinBlueComponent } = require('./providers/SendinBlueComponent'); -var { ActitoComponent } = require('./providers/ActitoComponent'); -var { ProfileListComponent } = require('../esp/profile-list'); -var { getEspIds } = require('../../utils/apis'); -var { SEND_MODE } = require('../../constant/send-mode'); -var { ESP_TYPE } = require('../../constant/esp-type'); +const Vue = require('vue/dist/vue.common'); +const { SendinBlueComponent } = require('./providers/SendinBlueComponent'); +const { ActitoComponent } = require('./providers/ActitoComponent'); +const { ModalComponent } = require('../modal/modalComponent'); +const { getEspIds } = require('../../utils/apis'); +const { SEND_MODE } = require('../../constant/send-mode'); +const { ESP_TYPE } = require('../../constant/esp-type'); -var { getCampaignDetail, getProfileDetail } = require('../../../vue/utils/apis'); -var axios = require('axios'); +const { getCampaignDetail, getProfileDetail } = require('../../../vue/utils/apis'); +const axios = require('axios'); -const EspComponent = Vue.component('esp-form', { +const EspComponent = Vue.component('EspForm', { components: { SendinBlueComponent, ActitoComponent, - ProfileListComponent + ModalComponent }, props: { vm: { type: Object, default: () => ({}) }, }, - template: ` -
-
- -
-
- - `, data: () => ({ mailingId: null, - loading: false, - loadingExport: false, + isLoading: false, + isLoadingExport: false, selectedProfile: null, - dialog: false, - modalInstance: null, type: SEND_MODE.CREATION, campaignId: null, espIds: [], fetchedProfile: {}, }), + computed: { + espComponent() { + switch (this.selectedProfile?.type) { + case ESP_TYPE.ACTITO: + return 'ActitoComponent'; + case ESP_TYPE.SENDINBLUE: + return 'SendinBlueComponent'; + default: + return 'SendinBlueComponent'; + } + } + }, mounted() { this.mailingId = this.vm?.metadata?.id; - const modalRef = this.$refs.modalRef; - const options = { - dismissible: false - }; - this.modalInstance = M.Modal.init(modalRef, options); this.fetchData(); this.subscriptions = [ this.vm.selectedProfile.subscribe(this.handleProfileSelect) @@ -83,21 +52,21 @@ const EspComponent = Vue.component('esp-form', { }, methods: { fetchData() { - this.loading = false; + this.isLoading = true; return axios.get(getEspIds({mailingId: this.mailingId })) .then((response) => { - this.loading = false; this.espIds = (response?.data.result || []); }).catch((error) => { // handle error + console.log(error); this.vm.notifier.error(this.vm.t('error-server')); }).finally(()=> { - this.loading = true; + this.isLoading = false; }); }, fetchProfileData(message) { - this.loading = true; - let getProfileApi = this.type === SEND_MODE.CREATION ? + this.isLoading = true; + const getProfileApi = this.type === SEND_MODE.CREATION ? getProfileDetail({ profileId: this.selectedProfile?.id }) : getCampaignDetail({ profileId: this.selectedProfile?.id, campaignId: this.campaignId }); @@ -126,9 +95,10 @@ const EspComponent = Vue.component('esp-form', { M.updateTextFields(); }).catch((error) => { // handle error + console.log(error); this.vm.notifier.error(this.vm.t('error-server')); }).finally(() => { - this.loading = false; + this.isLoading = false; }); }, handleProfileSelect(profile) { @@ -156,10 +126,10 @@ const EspComponent = Vue.component('esp-form', { } }, openModal() { - this.modalInstance?.open(); + this.$refs.modalRef?.openModal(); }, closeModal() { - this.modalInstance?.close(); + this.$refs.modalRef?.closeModal(); }, submitEsp(data) { @@ -167,7 +137,7 @@ const EspComponent = Vue.component('esp-form', { return; } - this.loadingExport = true; + this.isLoadingExport = true; const unprocessedHtml = this.vm.exportHTML(); axios.post(this.vm.metadata.url.sendCampaignMail, { @@ -195,22 +165,31 @@ const EspComponent = Vue.component('esp-form', { this.vm.notifier.error(this.vm.t(errorMessageCode)); }).finally(()=> { - this.loadingExport = false; + this.isLoadingExport = false; }); } }, - computed: { - espComponent() { - switch (this.selectedProfile?.type) { - case ESP_TYPE.ACTITO: - return 'ActitoComponent'; - case ESP_TYPE.SENDINBLUE: - return 'SendinBlueComponent'; - default: - return 'SendinBlueComponent'; - } - } - } + template: ` + + + + + ` }); module.exports = { diff --git a/packages/editor/src/js/vue/components/esp/profile-list.js b/packages/editor/src/js/vue/components/esp/profile-list.js index d88b2f26..bb60daf7 100644 --- a/packages/editor/src/js/vue/components/esp/profile-list.js +++ b/packages/editor/src/js/vue/components/esp/profile-list.js @@ -1,20 +1,11 @@ -var Vue = require('vue/dist/vue.common'); -var axios = require('axios'); +const Vue = require('vue/dist/vue.common'); +const axios = require('axios'); -const ProfileListComponent = Vue.component('profile-list', { +const ProfileListComponent = Vue.component('ProfileList', { props: { vm: { type: Object, default: () => ({}) }, selectProfile: { type: Function, default: () => {}} }, - template: ` -
- - {{ profile.name }} - -
- `, data: () => ({ profiles: [], }), @@ -33,10 +24,20 @@ const ProfileListComponent = Vue.component('profile-list', { this.profiles = response?.data?.result; }).catch((error) => { // handle error + console.log(error); vm.notifier.error(this.vm.t('error-server')); }); } }, + template: ` +
+ + {{ profile.name }} + +
+ `, }); module.exports = { diff --git a/packages/editor/src/js/vue/components/esp/providers/ActitoComponent.js b/packages/editor/src/js/vue/components/esp/providers/ActitoComponent.js index 515bbfb1..1498fab7 100644 --- a/packages/editor/src/js/vue/components/esp/providers/ActitoComponent.js +++ b/packages/editor/src/js/vue/components/esp/providers/ActitoComponent.js @@ -1,16 +1,17 @@ -import { required } from "vuelidate/lib/validators"; - -var Vue = require('vue/dist/vue.common'); -var { SEND_MODE } = require('../../../constant/send-mode'); -var { ESP_TYPE } = require('../../../constant/esp-type'); -var { validationMixin } = require('vuelidate'); +import { required } from 'vuelidate/lib/validators'; +const Vue = require('vue/dist/vue.common'); +const { SEND_MODE } = require('../../../constant/send-mode'); +const { ESP_TYPE } = require('../../../constant/esp-type'); +const { validationMixin } = require('vuelidate'); +const styleHelper = require('../../../utils/style/styleHelper'); const ActitoComponent = Vue.component('ActitoComponent', { + mixins: [validationMixin], props: { vm: { type: Object, default: () => ({}) }, campaignMailName: { type: String, default: null}, - loading: { type: Boolean, default: false}, + isLoading: { type: Boolean, default: false}, closeModal: { type: Function, default: () => {}}, espId: { type: String, default: null }, selectedProfile: { type: Object, default: () => ({}) }, @@ -18,7 +19,77 @@ const ActitoComponent = Vue.component('ActitoComponent', { campaignId: { type: String, default: null }, type: { type: Number, default: SEND_MODE.CREATION }, }, - mixins: [validationMixin], + data() { + + return { + profile: { + campaignMailName: '', + entity: '', + encodingType: '', + supportedLanguage: '', + targetTable: '', + senderMail: '', + replyTo: '', + subject: '', + type: ESP_TYPE.SENDINBLUE, + }, + style: styleHelper + } + }, + computed: { + isEditMode() { + return this.type === SEND_MODE.EDIT + } + }, + mounted() { + // prevent error inside the console, checking data before destructing it + if(!this.fetchedProfile || !this.fetchedProfile.additionalApiData) { + return; + } + + const { + subject, + campaignMailName, + id, + additionalApiData: { + entity, + targetTable, + senderMail, + encodingType, + supportedLanguage, + replyTo + } + } = this.fetchedProfile; + + this.profile = { + id: id ?? '', + campaignMailName: campaignMailName ?? '', + entity: entity ?? '', + encodingType: encodingType ?? '', + supportedLanguage: supportedLanguage ?? '', + targetTable: targetTable ?? '', + senderMail: senderMail ?? '', + replyTo: replyTo ?? '', + subject: subject ?? '', + } + + M.updateTextFields(); + }, + methods: { + onSubmit() { + M.updateTextFields(); + this.$v.$touch(); + + if (this.$v.$invalid) { + return; + } + + this.$emit('submit', this.profile); + }, + contentSendTypeLowerCase() { + return this.fetchedProfile?.contentSendType?.toString()?.toLowerCase() ?? 'mail'; + }, + }, template: `
`, - data() { - - return { - profile: { - campaignMailName: '', - entity: '', - encodingType: '', - supportedLanguage: '', - targetTable: '', - senderMail: '', - replyTo: '', - subject: '', - type: ESP_TYPE.SENDINBLUE, - }, - style: { - mb0:{ - marginBottom: 0, - }, - mt0:{ - marginTop: 0, - }, - pl4:{ - paddingLeft: '40px', - }, - floatLeft: { - float: 'left' - }, - colorOrange:{ - color: '#f57c00' - } - } - } - }, validations() { return { profile: { @@ -226,58 +264,6 @@ const ActitoComponent = Vue.component('ActitoComponent', { } } }, - mounted() { - - const { - subject, - campaignMailName, - id, - additionalApiData: { - entity, - targetTable, - senderMail, - encodingType, - supportedLanguage, - replyTo - } - } = this.fetchedProfile; - - this.profile = { - id: id ?? '', - campaignMailName: campaignMailName ?? '', - entity: entity ?? '', - encodingType: encodingType ?? '', - supportedLanguage: supportedLanguage ?? '', - targetTable: targetTable ?? '', - senderMail: senderMail ?? '', - replyTo: replyTo ?? '', - subject: subject ?? '', - } - - M.updateTextFields(); - }, - computed: { - isEditMode() { - return this.type === SEND_MODE.EDIT - } - }, - methods: { - onSubmit() { - M.updateTextFields(); - this.$v.$touch(); - - if (this.$v.$invalid) { - return; - } - - this.profile.campaignMailName = this.profile.campaignMailName?.replace(/[^A-Z0-9]+/ig, "_"); - - this.$emit('submit', this.profile); - }, - contentSendTypeLowerCase() { - return this.fetchedProfile?.contentSendType?.toString()?.toLowerCase() ?? 'mail'; - }, - }, }) module.exports = { diff --git a/packages/editor/src/js/vue/components/esp/providers/SendinBlueComponent.js b/packages/editor/src/js/vue/components/esp/providers/SendinBlueComponent.js index 7f5e941c..c6a5614a 100644 --- a/packages/editor/src/js/vue/components/esp/providers/SendinBlueComponent.js +++ b/packages/editor/src/js/vue/components/esp/providers/SendinBlueComponent.js @@ -1,15 +1,17 @@ -import {required} from "vuelidate/lib/validators"; +import {required} from 'vuelidate/lib/validators'; -var Vue = require('vue/dist/vue.common'); -var { SEND_MODE } = require('../../../constant/send-mode'); -var { ESP_TYPE } = require('../../../constant/esp-type'); -var { validationMixin } = require('vuelidate'); +const Vue = require('vue/dist/vue.common'); +const { SEND_MODE } = require('../../../constant/send-mode'); +const { ESP_TYPE } = require('../../../constant/esp-type'); +const { validationMixin } = require('vuelidate'); +const styleHelper = require('../../../utils/style/styleHelper'); const SendinBlueComponent = Vue.component('SendinBlueComponent', { + mixins: [validationMixin], props: { vm: { type: Object, default: () => ({}) }, - campaignMailName: { type: String, default: null}, - loading: { type: Boolean, default: false}, + campaignMailName: { type: String, default: null }, + isLoading: { type: Boolean, default: false}, closeModal: { type: Function, default: () => {}}, espId: { type: String, default: null }, selectedProfile: { type: Object, default: () => ({}) }, @@ -17,7 +19,64 @@ const SendinBlueComponent = Vue.component('SendinBlueComponent', { campaignId: { type: String, default: null }, type: { type: Number, default: SEND_MODE.CREATION }, }, - mixins: [validationMixin], + data() { + return { + profile: { + campaignMailName: '', + senderName: '', + senderMail: '', + replyTo: '', + subject: '', + type: ESP_TYPE.SENDINBLUE, + }, + style: styleHelper + } + }, + computed: { + isEditMode() { + return this.type === SEND_MODE.EDIT + }, + nameLabelText() { + return this.contentSendTypeLowerCase() + 'Name' + }, + subjectLabelText() { + return this.contentSendTypeLowerCase() + 'Subject' + }, + nameRequiredText() { + return this.contentSendTypeLowerCase() + '-name-required' + }, + subjectRequiredText() { + return this.contentSendTypeLowerCase() + '-subject-required' + } + }, + mounted() { + + const { campaignMailName, subject, id, additionalApiData: { senderName, senderMail, replyTo } } = this.fetchedProfile; + + this.profile = { + campaignMailName: campaignMailName ?? '', + senderName: senderName ?? '', + senderMail: senderMail ?? '', + replyTo: replyTo ?? '', + subject: subject ?? '', + id: id ?? '', + }; + M.updateTextFields(); + }, + methods: { + onSubmit() { + M.updateTextFields(); + this.$v.$touch(); + if (this.$v.$invalid) { + return; + } + + this.$emit('submit', this.profile); + }, + contentSendTypeLowerCase() { + return this.fetchedProfile?.contentSendType?.toString()?.toLowerCase() ?? 'mail'; + }, + }, template: `
`, - data() { - return { - profile: { - campaignMailName: '', - senderName: '', - senderMail: '', - replyTo: '', - subject: '', - type: ESP_TYPE.SENDINBLUE, - }, - style: { - mb0:{ - marginBottom: 0, - }, - mt0:{ - marginTop: 0, - }, - pl4:{ - paddingLeft: '40px', - }, - floatLeft: { - float: 'left' - }, - colorOrange:{ - color: '#f57c00' - } - } - } - }, validations() { return { profile: { @@ -184,51 +214,6 @@ const SendinBlueComponent = Vue.component('SendinBlueComponent', { } } }, - mounted() { - - const { campaignMailName, subject, id, additionalApiData: { senderName, senderMail, replyTo } } = this.fetchedProfile; - - this.profile = { - campaignMailName: campaignMailName ?? '', - senderName: senderName ?? '', - senderMail: senderMail ?? '', - replyTo: replyTo ?? '', - subject: subject ?? '', - id: id ?? '', - }; - M.updateTextFields(); - }, - computed: { - isEditMode() { - return this.type === SEND_MODE.EDIT - }, - nameLabelText() { - return this.contentSendTypeLowerCase() + 'Name' - }, - subjectLabelText() { - return this.contentSendTypeLowerCase() + 'Subject' - }, - nameRequiredText() { - return this.contentSendTypeLowerCase() + '-name-required' - }, - subjectRequiredText() { - return this.contentSendTypeLowerCase() + '-subject-required' - } - }, - methods: { - onSubmit() { - M.updateTextFields(); - this.$v.$touch(); - if (this.$v.$invalid) { - return; - } - - this.$emit('submit', this.profile); - }, - contentSendTypeLowerCase() { - return this.fetchedProfile?.contentSendType?.toString()?.toLowerCase() ?? 'mail'; - }, - }, }) module.exports = { diff --git a/packages/editor/src/js/vue/components/modal/modalComponent.js b/packages/editor/src/js/vue/components/modal/modalComponent.js new file mode 100644 index 00000000..40212c43 --- /dev/null +++ b/packages/editor/src/js/vue/components/modal/modalComponent.js @@ -0,0 +1,61 @@ +const Vue = require('vue/dist/vue.common'); + +const ModalComponent = Vue.component('ModalComponent', { + props: { + isLoading: { + type: Boolean, + default: false, + }, + onClose: { + type: Function, + default: () => {}, + }, + }, + data: () => ({ + modalInstance: null, + }), + mounted() { + const modalRef = this.$refs.modalRef; + const options = { + dismissible: false, + }; + this.modalInstance = M.Modal.init(modalRef, options); + }, + methods: { + openModal() { + this.modalInstance?.open(); + }, + closeModal() { + this.modalInstance?.close(); + if (typeof this.onClose === 'function') { + this.onClose(); + } + }, + }, + template: ` +
+
+ +
+
+ `, +}); + +module.exports = { + ModalComponent, +}; diff --git a/packages/editor/src/js/vue/components/select/selectComponent.js b/packages/editor/src/js/vue/components/select/selectComponent.js new file mode 100644 index 00000000..5eef71fa --- /dev/null +++ b/packages/editor/src/js/vue/components/select/selectComponent.js @@ -0,0 +1,34 @@ + +const Vue = require('vue/dist/vue.common'); +const vSelect = require('vue-select'); + +const SelectComponent = Vue.component('SelectComponent', { + components: { + VueSelect: vSelect.VueSelect + }, + props: { + value: { type: Object, default: () => ({}) }, + }, + methods: { + handleSelected(value) { + this.$emit('selected', value); + }, + handleInput(value) { + this.$emit('input', value); + } + }, + template: ` + + ` +}); + +module.exports = { + SelectComponent, +}; + \ No newline at end of file diff --git a/packages/editor/src/js/vue/components/send-test/test-modal.js b/packages/editor/src/js/vue/components/send-test/test-modal.js new file mode 100644 index 00000000..a7a833fa --- /dev/null +++ b/packages/editor/src/js/vue/components/send-test/test-modal.js @@ -0,0 +1,202 @@ +const Vue = require('vue/dist/vue.common'); +const isEmail = require('validator/lib/isEmail'); +const { validationMixin } = require('vuelidate'); +const { ModalComponent } = require('../modal/modalComponent'); +const { SelectComponent } = require('../select/selectComponent'); +const { getEmailGroups, sendTestEmails } = require('../../utils/apis'); +const styleHelper = require('../../utils/style/styleHelper'); + +const axios = require('axios'); + +const TestModalComponent = Vue.component('TestModal', { + components: { + ModalComponent, + SelectComponent + }, + mixins: [validationMixin], + props: { + vm: { type: Object, default: () => ({}) }, + }, + data: () => ({ + inputEmailsTest: '', + selectedEmailGroup: null, + isLoading: false, + isLoadingEmailGroups: false, + subscriptions: [], + style: styleHelper, + emailsGroups: [] + }), + computed: { + disableSendTestSubmitButton () { + return this.isLoading || + this.$v.$invalid || + ( (!this.selectedEmailGroup || !this.selectedEmailGroup.code) && !this.inputEmailsTest) + }, + displayEmailsGroupsSelect() { + return !this.isLoadingEmailGroups && Array.isArray(this.emailsGroups) && this.emailsGroups.length > 0 ; + } + }, + mounted() { + this.subscriptions = [ + this.vm.openTestModal.subscribe(this.handleOpenTestModalChange), + ]; + this.fetchEmailsGroups(); + M.updateTextFields(); + }, + beforeDestroy() { + this.subscriptions.forEach((subscription) => subscription.dispose()); + }, + methods: { + openModal() { + this.$refs.modalRef?.openModal(); + }, + closeModal() { + this.inputEmailsTest = ''; + this.selectedEmailGroup = null; + this.$refs.modalRef?.closeModal(); + }, + handleOpenTestModalChange(value) { + if (value === true) { + this.openModal(); + } + }, + handleOnClose() { + this.vm.openTestModal(false); + }, + fetchEmailsGroups() { + this.isLoadingEmailGroups = true; + return axios.get(getEmailGroups({groupId: this.vm?.metadata?.groupId })) + .then((response) => { + const { items: emailsGroups } = response.data; + this.emailsGroups = emailsGroups.map(emailsGroup => ({ + label: emailsGroup.name, + code: emailsGroup.id + })); + M.updateTextFields(); + }).catch((error) => { + console.error(error); + }).finally(()=> { + this.isLoadingEmailGroups = false; + }); + }, + handleOnSubmit() { + this.$v.$touch(); + + if (!this.$v.$invalid) { + this.sendTestData({ inputEmailsTest: this.$v.inputEmailsTest.$model}); + } + }, + sendTestData({ inputEmailsTest }) { + this.isLoading = true; + let sendTestEmailsData = { + rcpt: inputEmailsTest, + html: this.vm.exportHTML() + }; + + if(this.selectedEmailGroup && this.selectedEmailGroup?.code) { + sendTestEmailsData = { ...sendTestEmailsData, emailsGroupId: this.selectedEmailGroup?.code } + } + + return axios.post(sendTestEmails({ mailingId: this.vm?.metadata?.id }), sendTestEmailsData) + .then( () => { + this.vm.notifier.success(this.vm.t('send-test-success')); + }).catch(() => { + this.vm.notifier.error(this.vm.t('send-test-error')); + }) + .finally(() => { + this.isLoading = false; + this.closeModal(); + }); + }, + handleOnInput() { + this.$v.inputEmailsTest.$touch(); + }, + handleSelectedEmailGroup(selectedEmailGroup) { + this.selectedEmailGroup = selectedEmailGroup; + } + }, + template: ` + + + + + `, + validations() { + return { + inputEmailsTest: { + allMustBeEmails(value) { + if(!value) { + return true; + } + + const emails = value.split(';'); + for (const address of emails) { + if (!isEmail(address)) { + return false; + } + } + return true; + }, + }, + }; + }, +}); + +module.exports = { + TestModalComponent, +}; diff --git a/packages/editor/src/js/vue/constant/esp-type.js b/packages/editor/src/js/vue/constant/esp-type.js index 29806563..a65e56ed 100644 --- a/packages/editor/src/js/vue/constant/esp-type.js +++ b/packages/editor/src/js/vue/constant/esp-type.js @@ -1,5 +1,5 @@ -const EspType = { "SENDINBLUE":"SENDINBLUE", "ACTITO":"ACTITO" } +const EspType = { 'SENDINBLUE':'SENDINBLUE', 'ACTITO':'ACTITO' } Object.freeze(EspType); module.exports = { diff --git a/packages/editor/src/js/vue/constant/send-mode.js b/packages/editor/src/js/vue/constant/send-mode.js index c0f488a5..7424513c 100644 --- a/packages/editor/src/js/vue/constant/send-mode.js +++ b/packages/editor/src/js/vue/constant/send-mode.js @@ -1,5 +1,5 @@ -const SendMode = { "EDIT":1, "CREATION":2 } +const SendMode = { 'EDIT':1, 'CREATION':2 } Object.freeze(SendMode); module.exports = { diff --git a/packages/editor/src/js/vue/utils/apis.js b/packages/editor/src/js/vue/utils/apis.js index ea70906b..727174a3 100644 --- a/packages/editor/src/js/vue/utils/apis.js +++ b/packages/editor/src/js/vue/utils/apis.js @@ -14,8 +14,18 @@ function getCampaignDetail({ profileId, campaignId }) { } +function getEmailGroups({groupId}) { + return `${prefixApi}/groups/${groupId}/email-groups` +} + +function sendTestEmails({ mailingId }) { + return `${prefixApi}/mailings/${mailingId}/mosaico/send-test-mail`; +} + module.exports = { getEspIds, getProfileDetail, - getCampaignDetail + getCampaignDetail, + getEmailGroups, + sendTestEmails } diff --git a/packages/editor/src/js/vue/utils/style/styleHelper.js b/packages/editor/src/js/vue/utils/style/styleHelper.js new file mode 100644 index 00000000..3c8e286a --- /dev/null +++ b/packages/editor/src/js/vue/utils/style/styleHelper.js @@ -0,0 +1,17 @@ +export const styleHelper = { + mb0:{ + marginBottom: 0, + }, + mt0:{ + marginTop: 0, + }, + pl4:{ + paddingLeft: '40px', + }, + floatLeft: { + float: 'left' + }, + colorOrange:{ + color: '#f57c00' + } + }; \ No newline at end of file diff --git a/packages/editor/src/js/vue/vuePlugin.js b/packages/editor/src/js/vue/vuePlugin.js index cfcc4a28..5db5bc14 100644 --- a/packages/editor/src/js/vue/vuePlugin.js +++ b/packages/editor/src/js/vue/vuePlugin.js @@ -1,24 +1,26 @@ -var Vue = require('vue/dist/vue.common'); -var EspComponent = require('./components/esp/esp-send-mail'); - +const Vue = require('vue/dist/vue.common'); +const EspComponent = require('./components/esp/esp-send-mail'); +const { TestModalComponent } = require('./components/send-test/test-modal'); module.exports = { - viewModel(vm, ko) { - }, + viewModel(vm, ko) {}, init(vm) { // Init VueJS component - Vue.component('app-vue', { + Vue.component('AppVue', { components: { - EspComponent + EspComponent, + TestModalComponent, }, - template: ` - - `, data: () => ({ viewModel: vm, }), - mounted() { - }, - }) + mounted() {}, + template: ` +
+ + +
+ `, + }); new Vue({ el: '#espModal' }); }, diff --git a/packages/server/common/models.common.js b/packages/server/common/models.common.js index 9641bcc0..336a9bdf 100644 --- a/packages/server/common/models.common.js +++ b/packages/server/common/models.common.js @@ -4,6 +4,8 @@ const mongoose = require('mongoose'); mongoose.Promise = global.Promise; // Use native promises +const modelNames = require('../constant/model.names.js'); + const UserSchema = require('../user/user.schema.js'); const TemplateSchema = require('../template/template.schema.js'); const MailingSchema = require('../mailing/mailing.schema.js'); @@ -14,8 +16,7 @@ const GallerySchema = require('../image/gallery.schema.js'); const OAuthClientsSchema = require('../account/oauth-clients.schema.js'); const OAuthTokensSchema = require('../account/oauth-tokens.schema.js'); const OAuthCodesSchema = require('../account/oauth-codes.schema.js'); - -const modelNames = require('../constant/model.names.js'); +const EmailsGroupSchema = require('../emails-group/emails-group.schema'); const FolderSchema = require('../folder/folder.schema'); const WorkspaceSchema = require('../workspace/workspace.schema'); @@ -28,6 +29,10 @@ const Templates = mongoose.model(modelNames.TemplateModel, TemplateSchema); const Mailings = mongoose.model(modelNames.MailingModel, MailingSchema); const Groups = mongoose.model(modelNames.GroupModel, GroupSchema); const Folders = mongoose.model(modelNames.FolderModel, FolderSchema); +const EmailsGroups = mongoose.model( + modelNames.EmailsGroupModal, + EmailsGroupSchema +); const Workspaces = mongoose.model(modelNames.WorkspaceModel, WorkspaceSchema); const Profiles = mongoose.model(modelNames.ProfileModel, ProfileSchema); const CacheImages = mongoose.model( @@ -47,6 +52,7 @@ module.exports = { // Compiled schema Users, Folders, + EmailsGroups, Workspaces, Templates, Mailings, diff --git a/packages/server/constant/error-codes.js b/packages/server/constant/error-codes.js index 4c3824be..40e9d48c 100644 --- a/packages/server/constant/error-codes.js +++ b/packages/server/constant/error-codes.js @@ -2,18 +2,27 @@ module.exports = { WORKSPACE_ALREADY_EXISTS: 'WORKSPACE_ALREADY_EXISTS', + UNAUTHORIZED: 'UNAUTHORIZED', FORBIDDEN_WORKSPACE_CREATION: 'FORBIDDEN_WORKSPACE_CREATION', FORBIDDEN_WORKSPACE_RETRIEVAL: 'FORBIDDEN_WORKSPACE_RETRIEVAL', FORBIDDEN_PROFILE_ACCESS: 'FORBIDDEN_PROFILE_ACCESS', WORKSPACE_ID_NOT_PROVIDED: 'WORKSPACE_ID_NOT_PROVIDED', WORKSPACE_NOT_FOUND: 'WORKSPACE_NOT_FOUND', + TOO_MUCH_RECURRENT_LOOP: 'TOO_MUCH_RECURRENT_LOOP', + GROUP_NOT_FOUND: 'GROUP_NOT_FOUND', + EMAIL_GROUP_NOT_FOUND: 'EMAIL_GROUP_NOT_FOUND', + EMAIL_GROUP_NAME_ALREADY_EXIST: 'EMAIL_GROUP_NAME_ALREADY_EXIST', + FAILED_EMAIL_GROUP_DELETE: 'FAILED_EMAIL_GROUP_DELETE', TEMPLATE_NOT_FOUND: 'TEMPLATE_NOT_FOUND', FORBIDDEN_MAILING_RENAME: 'FORBIDDEN_MAILING_RENAME', + FORBIDDEN_MAILING_DELETE: 'FORBIDDEN_MAILING_DELETE', + FORBIDDEN_MAILING_COPY: 'FORBIDDEN_MAILING_COPY', FAILED_MAILING_RENAME: 'FAILED_MAILING_RENAME', + MISSING_GROUP_PARAM: 'MISSING_GROUP_PARAM', FAILED_FOLDER_RENAME: 'FAILED_FOLDER_RENAME', - FORBIDDEN_MAILING_DELETE: 'FORBIDDEN_MAILING_DELETE', FAILED_MAILING_DELETE: 'FAILED_MAILING_DELETE', - FORBIDDEN_MAILING_COPY: 'FORBIDDEN_MAILING_COPY', + MISSING_EMAIL_GROUP_NAME_PARAM: 'MISSING_EMAIL_GROUP_NAME_PARAM', + MISSING_EMAIL_GROUP_EMAILS_PARAM: 'MISSING_EMAIL_GROUP_EMAILS_PARAM', MAILING_MISSING_SOURCE: 'MAILING_MISSING_SOURCE', MAILING_HTML_MISSING: 'MAILING_HTML_MISSING', FAILED_MAILING_COPY: 'FAILED_MAILING_COPY', @@ -57,6 +66,7 @@ module.exports = { MALFORMAT_ESP_RESPONSE: 'MALFORMAT_ESP_RESPONSE', API_CALL_IS_NOT_A_FUNCTION: 'API_CALL_IS_NOT_A_FUNCTION', CAMPAIGN_ID_MUST_BE_DEFINED: 'CAMPAIGN_ID_MUST_BE_DEFINED', + MISSING_DOWNLOAD_OPTIONS: 'MISSING_DOWNLOAD_OPTIONS', MISSING_PROPERTIES_ESP_ID: 'MISSING_PROPERTIES_ESP_ID', MISSING_PROPERTIES_CAMPAIGN_MAIL_ID: 'MISSING_PROPERTIES_CAMPAIGN_MAIL_ID', MISSING_PROPERTIES_ENTITY: 'MISSING_PROPERTIES_ENTITY', diff --git a/packages/server/constant/model.names.js b/packages/server/constant/model.names.js index cc15329a..468f9ee5 100644 --- a/packages/server/constant/model.names.js +++ b/packages/server/constant/model.names.js @@ -9,6 +9,7 @@ module.exports = Object.freeze({ GroupModel: 'Company', CacheImageModel: 'Cacheimage', FolderModel: 'Folder', + EmailsGroupModal: 'EmailsGroup', WorkspaceModel: 'Workspace', GalleryModel: 'Gallery', // OAuth diff --git a/packages/server/email-templates/reset-password.html b/packages/server/email-templates/reset-password.html index bf5e91ea..f84d40ca 100644 --- a/packages/server/email-templates/reset-password.html +++ b/packages/server/email-templates/reset-password.html @@ -1,200 +1,433 @@ - - - - - - - - - Badsender - - + + + + + + + + + + + Le Patron - + + + + - - - - -
- -
- - - - +
 
- - -
-
- - - - - - - - - - - - - - - - -
- - - - - - - -
-
- - Badsender - -
-
 
- {% if (o.type === 'admin') { %} - - - - {% } %} - - - - -
- {%=o.t.title%} -
- {%=o.t.desc%} -
-
- {%=o.url%} -
 
- - - - - - - - - - -
-
- - {%=o.t.reset%} - -
-
 
- - - - - - - - + + + + + +
+
+ + + + + - - -
+
+ + - - - -
- -
-
- - - - +
+
+
+
+ + + + + +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/packages/server/emails-group/emails-group.controller.js b/packages/server/emails-group/emails-group.controller.js new file mode 100644 index 00000000..76a508d0 --- /dev/null +++ b/packages/server/emails-group/emails-group.controller.js @@ -0,0 +1,143 @@ +'use-strict'; + +const asyncHandler = require('express-async-handler'); +const logger = require('../utils/logger'); + +const emailsGroupService = require('./emails-group.service'); + +module.exports = { + listEmailsGroups: asyncHandler(listEmailGroups), + createEmailsGroup: asyncHandler(createEmailGroup), + getEmailsGroup: asyncHandler(getEmailsGroup), + deleteEmailsGroup: asyncHandler(deleteEmailsGroup), + editEmailsGroup: asyncHandler(editEmailGroup), +}; + +/** + * @api {get} /emails-group list of emails groups + * @apiPermission group_admin + * @apiName listEmailsGroups + * @apiGroup EmailsGroups + * + * @apiUse emailsGroup + * @apiSuccess {emailsGroup[]} items list of emails groups + */ +async function listEmailGroups(req, res) { + logger.log('emailsGroupController:listEmailsGroups'); + const { user } = req; + const emailsGroups = await emailsGroupService.listEmailsGroups( + user?.group?.id + ); + res.json({ + items: emailsGroups, + }); +} + +/** + * @api {post} /emails-group/ create emails group + * @apiPermission group_admin + * @apiName CreateEmailsGroup + * @apiGroup emailsGroups + * + * @apiParam (Body) {String} name + * @apiParam (Body) {String} emails + * + * @apiUse emailsGroup + * @apiSuccess {emailsGroup} created Emails group + */ +async function createEmailGroup(req, res) { + logger.log('emailsGroupController:createEmailsGroup'); + const { + user, + body: { name, emails }, + } = req; + + const emailGroup = await emailsGroupService.createEmailsGroup({ + name, + emails, + user, + }); + + res.json({ + emailGroup, + }); +} + +/** + * @api {get} /email-group/:emailsGroupId Emails Group update + * @apiPermission group_admin + * @apiName getEmailsGroup + * @apiGroup EmailsGroup + * + * @apiUse emailsGroup + * @apiSuccess {EmailsGroup} get Emails group from id + */ +async function getEmailsGroup(req, res) { + logger.log('emailsGroupController:getEmailsGroup'); + const { + user, + params: { emailsGroupId }, + } = req; + + const emailsGroup = await emailsGroupService.getEmailsGroup({ + emailsGroupId, + userGroupId: user?.group?.id, + }); + + res.send(emailsGroup); +} + +/** + * @api {delete} /emails-group/:emailsGroupId emails group delete + * @apiPermission group_admin + * @apiName DeleteEmailsGroup + * @apiGroup EmailsGroups + * + * @apiUse emailsGroup + * @apiSuccess {emailsGroup} emailsGroup deleted + */ + +async function deleteEmailsGroup(req, res) { + logger.log('emailsGroupController:deleteEmailsGroup'); + const { + user, + params: { emailsGroupId }, + } = req; + + await emailsGroupService.deleteEmailsGroup({ user, emailsGroupId }); + + res.status(204).send(); +} + +/** + * @api {put} /emails-group/:emailsGroupId email group edit + * @apiPermission group_admin + * @apiName PatchEmailsGroup + * @apiGroup EmailsGroup + * + * @apiParam {string} emailsGroupId + * + * @apiParam (Body) {String} name + * @apiParam (Body) {String} emails + * + * @apiUse emailsGroup + * @apiSuccess {emailsGroup} emails group edited + */ + +async function editEmailGroup(req, res) { + logger.log('emailGroupController:editEmailGroup'); + const { + user, + params: { emailsGroupId }, + body: { name, emails }, + } = req; + + await emailsGroupService.editEmailsGroup({ + emailsGroupId, + name, + emails, + user, + }); + + res.status(204).send(); +} diff --git a/packages/server/emails-group/emails-group.routes.js b/packages/server/emails-group/emails-group.routes.js new file mode 100644 index 00000000..99785980 --- /dev/null +++ b/packages/server/emails-group/emails-group.routes.js @@ -0,0 +1,19 @@ +'use strict'; + +const express = require('express'); +const router = express.Router(); + +const { GUARD_GROUP_ADMIN, GUARD_USER } = require('../account/auth.guard'); +const emailsGroup = require('./emails-group.controller'); + +router.get('', GUARD_USER, emailsGroup.listEmailsGroups); +router.post('', GUARD_GROUP_ADMIN, emailsGroup.createEmailsGroup); +router.get('/:emailsGroupId', GUARD_USER, emailsGroup.getEmailsGroup); +router.delete( + '/:emailsGroupId', + GUARD_GROUP_ADMIN, + emailsGroup.deleteEmailsGroup +); +router.patch('/:emailsGroupId', GUARD_GROUP_ADMIN, emailsGroup.editEmailsGroup); + +module.exports = router; diff --git a/packages/server/emails-group/emails-group.schema.js b/packages/server/emails-group/emails-group.schema.js new file mode 100644 index 00000000..b2dfcba6 --- /dev/null +++ b/packages/server/emails-group/emails-group.schema.js @@ -0,0 +1,38 @@ +'use strict'; + +const { Schema } = require('mongoose'); +const { ObjectId } = Schema.Types; +const { GroupModel } = require('../constant/model.names.js'); + +/** + * @apiDefine emailsGroup + * @apiSuccess {String} id + * @apiSuccess {String} name + * @apiSuccess {String} emails + * @apiSuccess {String} _company + */ + +const EmailsGroupSchema = Schema( + { + name: { + type: String, + required: [true, 'Emails group name is required'], + }, + emails: { + type: String, + required: [true, 'Emails group emails list is required'], + }, + _company: { + type: ObjectId, + ref: GroupModel, + alias: 'group', + }, + }, + { + timestamps: true, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + } +); + +module.exports = EmailsGroupSchema; diff --git a/packages/server/emails-group/emails-group.service.js b/packages/server/emails-group/emails-group.service.js new file mode 100644 index 00000000..31534730 --- /dev/null +++ b/packages/server/emails-group/emails-group.service.js @@ -0,0 +1,133 @@ +'use strict'; + +const { Types } = require('mongoose'); + +const { EmailsGroups } = require('../common/models.common.js'); +const { + NotFound, + InternalServerError, + Conflict, + BadRequest, + Unauthorized, +} = require('http-errors'); +const ERROR_CODES = require('../constant/error-codes.js'); +const logger = require('../utils/logger.js'); + +module.exports = { + listEmailsGroups, + createEmailsGroup, + getEmailsGroup, + deleteEmailsGroup, + editEmailsGroup, +}; + +// TODO complete listEmailGroups +async function listEmailsGroups(groupId) { + logger.log('emailsGroupService:ListEmailsGroups'); + if (!groupId) { + throw new BadRequest(ERROR_CODES.MISSING_GROUP_PARAM); + } + + return EmailsGroups.find({ + _company: groupId, + }).sort({ name: 1 }); +} + +async function createEmailsGroup({ name, emails, user }) { + logger.log('emailsGroupService:createEmailsGroup'); + checkNameAndEmailsExists({ name, emails }); + + if (!user) { + throw new Unauthorized(ERROR_CODES.UNAUTHORIZED); + } + + if (await EmailsGroups.exists({ name, _company: user.group.id })) { + throw new Conflict(ERROR_CODES.EMAIL_GROUP_NAME_ALREADY_EXIST); + } + + return EmailsGroups.create({ + name, + emails, + _company: user.group.id, + }); +} + +async function getEmailsGroup({ emailsGroupId, userGroupId }) { + logger.log('emailsGroupService:getEmailsGroup'); + await checkIfEmailGroupExist(emailsGroupId); + + const emailsGroup = await EmailsGroups.findById(emailsGroupId); + + if (emailsGroup.group?.toString() !== userGroupId) { + throw new NotFound(ERROR_CODES.EMAIL_GROUP_NOT_FOUND); + } + + return emailsGroup; +} + +async function editEmailsGroup({ emailsGroupId, name, emails, user }) { + logger.log('emailsGroupService:editEmailsGroup'); + checkNameAndEmailsExists({ name, emails }); + + if (!user) { + throw new Unauthorized(ERROR_CODES.UNAUTHORIZED); + } + + await checkIfEmailGroupExist(emailsGroupId); + + const emailsGroup = await EmailsGroups.findById(emailsGroupId); + + if (emailsGroup.group?.toString() !== user.group.id) { + throw new NotFound(ERROR_CODES.EMAIL_GROUP_NOT_FOUND); + } + + return EmailsGroups.updateOne( + { _id: Types.ObjectId(emailsGroupId) }, + { + name, + emails, + } + ); +} + +async function deleteEmailsGroup({ emailsGroupId, user }) { + logger.log('emailsGroupService:deleteEmailsGroup'); + const emailsGroup = await findOne(emailsGroupId); + + const deleteEmailsGroupResponse = await deleteOne(emailsGroup.id); + + if (emailsGroup.group?.toString() !== user.group.id) { + throw new NotFound(ERROR_CODES.EMAIL_GROUP_NOT_FOUND); + } + + if (deleteEmailsGroupResponse.ok !== 1) { + throw new InternalServerError(ERROR_CODES.FAILED_EMAIL_GROUP_DELETE); + } + + return deleteEmailsGroupResponse; +} + +async function deleteOne(emailsGroupId) { + return EmailsGroups.deleteOne({ _id: Types.ObjectId(emailsGroupId) }); +} + +async function findOne(emailsGroupId) { + await checkIfEmailGroupExist(emailsGroupId); + return EmailsGroups.findOne({ _id: Types.ObjectId(emailsGroupId) }); +} + +async function checkIfEmailGroupExist(emailsGroupId) { + if (!(await EmailsGroups.exists({ _id: Types.ObjectId(emailsGroupId) }))) { + throw new NotFound(ERROR_CODES.EMAIL_GROUP_NOT_FOUND); + } +} + +function checkNameAndEmailsExists({ name, emails }) { + if (!name) { + throw new BadRequest(ERROR_CODES.MISSING_EMAIL_GROUP_NAME_PARAM); + } + + if (!emails) { + throw new BadRequest(ERROR_CODES.MISSING_EMAIL_GROUP_NAME_PARAM); + } +} diff --git a/packages/server/esp/actito/actitoProvider.js b/packages/server/esp/actito/actitoProvider.js index 06fc6420..d2b96587 100644 --- a/packages/server/esp/actito/actitoProvider.js +++ b/packages/server/esp/actito/actitoProvider.js @@ -110,7 +110,7 @@ class ActitoProvider { }); const { - name, + displayName, from, replyTo, entityOfTarget, @@ -120,7 +120,7 @@ class ActitoProvider { } = apiEmailCampaignResult?.data; return { - name: name, + name: displayName, additionalApiData: { senderMail: from, replyTo: replyTo, @@ -292,6 +292,7 @@ class ActitoProvider { headers: headerAccess, } ), + isEdit: true, }); } @@ -302,6 +303,7 @@ class ActitoProvider { mailingId, entity, mailCampaignApi, + isEdit = false, }) { let campaignId; @@ -314,7 +316,7 @@ class ActitoProvider { from, entityOfTarget, supportedLanguages, - name, + displayName, replyTo, targetTable, encoding, @@ -329,16 +331,27 @@ class ActitoProvider { mailingId, }); - const createdCampaignMailResult = await mailCampaignApi({ + let mailCampaignApiData = { from, entityOfTarget, supportedLanguages, - name, + displayName, replyTo, targetTable, encoding, contentType, - }); + }; + + if (!isEdit) { + mailCampaignApiData = { + ...mailCampaignApiData, + name: displayName?.replace(/[^A-Z0-9]+/gi, '_'), + }; + } + + const createdCampaignMailResult = await mailCampaignApi( + mailCampaignApiData + ); if (!createdCampaignMailResult?.data?.campaignId) { throw new InternalServerError(ERROR_CODES.UNEXPECTED_ESP_RESPONSE); @@ -370,19 +383,19 @@ class ActitoProvider { from, entityOfTarget, supportedLanguages, - name, + displayName, replyTo, targetTable, encoding, subject, }; } catch (e) { - logger.error(e.response.data); + logger.error(e?.response?.data); if (campaignId) { await this.deleteCampaignMail({ campaignId, entity }); } - if (e.response.status === 409) { + if (e?.response?.status === 409) { throw new Conflict(ERROR_CODES.ALREADY_USED_MAIL_NAME); } @@ -412,7 +425,7 @@ class ActitoProvider { senderMail: from, entity: entityOfTarget, supportedLanguage, - name, + name: displayName, replyTo, targetTable, encodingType: encoding, @@ -429,7 +442,7 @@ class ActitoProvider { from, entityOfTarget, supportedLanguages: [supportedLanguage], - name, + displayName, replyTo, targetTable, encoding, diff --git a/packages/server/group/group.controller.js b/packages/server/group/group.controller.js index c2b17ef7..c6333b31 100644 --- a/packages/server/group/group.controller.js +++ b/packages/server/group/group.controller.js @@ -1,7 +1,7 @@ 'use strict'; const { pick } = require('lodash'); -const createError = require('http-errors'); +const { NotFound } = require('http-errors'); const asyncHandler = require('express-async-handler'); const { createWorkspace, @@ -10,6 +10,7 @@ const { const groupService = require('../group/group.service.js'); const profileService = require('../profile/profile.service.js'); +const emailsGroupService = require('../emails-group/emails-group.service.js'); const { Groups, Templates, Mailings } = require('../common/models.common.js'); @@ -23,6 +24,7 @@ module.exports = { readMailings: asyncHandler(readMailings), readProfiles: asyncHandler(readProfiles), readWorkspaces: asyncHandler(readWorkspaces), + readEmailGroups: asyncHandler(readEmailGroups), update: asyncHandler(update), }; @@ -103,7 +105,7 @@ async function seedGroups(req, res) { async function read(req, res) { const { groupId } = req.params; const group = await Groups.findById(groupId); - if (!group) throw new createError.NotFound(); + if (!group) throw new NotFound(); res.json(group); } @@ -146,10 +148,29 @@ async function readTemplates(req, res) { Groups.findById(groupId).select('_id'), Templates.findForApi({ _company: groupId }), ]); - if (!group) throw new createError.NotFound(); + if (!group) throw new NotFound(); res.json({ items: templates }); } +/** + * @api {get} /groups/:groupId/email-groups group emails + * @apiPermission group_admin + * @apiName GetEmailGroups + * @apiGroup Groups + * + * @apiParam {string} groupId + * + * @apiUse emailsGroup + * @apiSuccess {emailGroup[]} items list of email groups + */ + +async function readEmailGroups(req, res) { + const { groupId } = req.params; + const emailGroups = await emailsGroupService.listEmailsGroups(groupId); + if (!emailGroups) throw new NotFound(); + res.json({ items: emailGroups }); +} + /** * @api {GET} /groups/:groupId/profiles group profiles * @apiPermission admin @@ -186,7 +207,7 @@ async function readMailings(req, res) { Groups.findById(groupId).select('_id'), Mailings.findForApi({ _company: groupId }), ]); - if (!group) throw new createError.NotFound(); + if (!group) throw new NotFound(); res.json({ items: mailings }); } @@ -204,7 +225,7 @@ async function readMailings(req, res) { async function readWorkspaces(req, res, next) { const { groupId } = req.params; - if (!groupId) next(new createError.NotFound()); + if (!groupId) next(new NotFound()); const workspaces = await findWorkspaces({ groupId }); return res.json({ items: workspaces }); } diff --git a/packages/server/group/group.routes.js b/packages/server/group/group.routes.js index 33c8360a..d422fe0e 100644 --- a/packages/server/group/group.routes.js +++ b/packages/server/group/group.routes.js @@ -9,6 +9,7 @@ const { GUARD_ADMIN, GUARD_GROUP_ADMIN, guard, + GUARD_USER, } = require('../account/auth.guard.js'); const Roles = require('../account/roles.js'); const groups = require('./group.controller.js'); @@ -50,6 +51,12 @@ router.get( groups.readWorkspaces ); +router.get( + '/:groupId/email-groups', + GUARD_USER, // guard() will check if the user is logged + groups.readEmailGroups +); + router.get( '/:groupId/mailings', guard(), // guard() will check if the user is logged diff --git a/packages/server/html-templates/mosaico-editor.pug b/packages/server/html-templates/mosaico-editor.pug index bd57cbdb..c2e17309 100644 --- a/packages/server/html-templates/mosaico-editor.pug +++ b/packages/server/html-templates/mosaico-editor.pug @@ -12,7 +12,7 @@ html(lang=getLocale()) link(rel="canonical" href="http://agence.badsender.com") link(rel="shortcut icon" href="/favicon.png" type="image/png") link(rel="icon" href="/favicon.png" type="image/png") - + link(href='https://fonts.googleapis.com/css?family=PT+Sans:400,700' rel='stylesheet' type='text/css') meta(name="viewport" content="width=1024, initial-scale=1") diff --git a/packages/server/index.js b/packages/server/index.js index 285a8b17..f17d28f2 100644 --- a/packages/server/index.js +++ b/packages/server/index.js @@ -30,6 +30,7 @@ const templateRouter = require('./template/template.routes'); const userRouter = require('./user/user.routes'); 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; @@ -252,6 +253,7 @@ if (cluster.isMaster) { 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); diff --git a/packages/server/mailing/download-zip.controller.js b/packages/server/mailing/download-zip.controller.js index c4f85c50..fc5d46e0 100644 --- a/packages/server/mailing/download-zip.controller.js +++ b/packages/server/mailing/download-zip.controller.js @@ -3,9 +3,15 @@ // const cheerio = require('cheerio') const asyncHandler = require('express-async-handler'); const mailingService = require('./mailing.service.js'); +const logger = require('../utils/logger.js'); const archiver = require('archiver'); +const { InternalServerError } = require('http-errors'); +const { ERROR_CODES } = require('../constant/error-codes.js'); -module.exports = asyncHandler(downloadZip); +module.exports = { + downloadZip: asyncHandler(downloadZip), + downloadMultipleZip: asyncHandler(downloadMultipleZip), +}; // eslint-disable-next-line no-unused-vars function isHttpUrl(uri) { @@ -30,6 +36,8 @@ function isHttpUrl(uri) { // https://github.com/archiverjs/node-archiver/blob/master/examples/express.js // we need to keep the `next` callback to handle zip events async function downloadZip(req, res, next) { + logger.log('Calling downloadZip'); + const { user, body } = req; const { mailingId } = req.params; const { html, ...downloadOptions } = body; @@ -58,3 +66,35 @@ async function downloadZip(req, res, next) { // set the archive name processedArchive.finalize(); } + +async function downloadMultipleZip(req, res, next) { + logger.log('Calling downloadMultipleZip'); + const { user, body } = req; + const { mailingIds, downloadOptions } = body; + const archive = archiver('zip'); + archive.on('error', next); + + if (!downloadOptions) { + throw new InternalServerError(ERROR_CODES.MISSING_DOWNLOAD_OPTIONS); + } + + const { + archive: processedArchive, + name, + } = await mailingService.downloadMultipleZip({ + user, + archive, + mailingIds, + downloadOptions, + }); + res.header('Content-Type', 'application/zip'); + res.header('Content-Disposition', `attachment; filename="${name}.zip"`); + + archive.on('end', () => { + console.log(`Archive wrote ${archive.pointer()} bytes`); + res.end(); + }); + archive.pipe(res); + + processedArchive.finalize(); +} diff --git a/packages/server/mailing/mailing.controller.js b/packages/server/mailing/mailing.controller.js index 9100778b..aa3c981b 100644 --- a/packages/server/mailing/mailing.controller.js +++ b/packages/server/mailing/mailing.controller.js @@ -14,7 +14,10 @@ const simpleI18n = require('../helpers/server-simple-i18n.js'); const logger = require('../utils/logger.js'); const { Mailings, Galleries, Users } = require('../common/models.common.js'); const sendTestMail = require('./send-test-mail.controller.js'); -const downloadZip = require('./download-zip.controller.js'); +const { + downloadZip, + downloadMultipleZip, +} = require('./download-zip.controller.js'); const cleanTagName = require('../helpers/clean-tag-name.js'); const fileManager = require('../common/file-manage.service.js'); const modelsUtils = require('../utils/model.js'); @@ -36,6 +39,7 @@ module.exports = { duplicate: asyncHandler(duplicate), updateMosaico: asyncHandler(updateMosaico), bulkUpdate: asyncHandler(bulkUpdate), + downloadMultipleZip: asyncHandler(downloadMultipleZip), bulkDestroy: asyncHandler(bulkDestroy), delete: asyncHandler(deleteMailing), transferToUser: asyncHandler(transferToUser), @@ -152,8 +156,10 @@ async function read(req, res) { async function readMosaico(req, res) { const { mailingId } = req.params; + const { user } = req; const query = modelsUtils.addGroupFilter(req.user, { _id: mailingId }); const mailingForMosaico = await Mailings.findOneForMosaico( + user, query, req.user.lang ); @@ -362,10 +368,6 @@ async function updateMosaico(req, res) { throw new NotFound(ERROR_CODES.MAILING_NOT_FOUND); } - if (!requestHtml) { - throw new NotFound(ERROR_CODES.MAILING_HTML_MISSING); - } - if (!user.isAdmin) { const { _workspace, _parentFolder } = mailing; @@ -386,14 +388,19 @@ async function updateMosaico(req, res) { mailing.data = req.body.data || mailing.data; mailing.name = - modelsUtils.normalizeString(req.body.name) || + modelsUtils.trimString(req.body.name) || simpleI18n('default-mailing-name', user.lang); // http://mongoosejs.com/docs/schematypes.html#mixed mailing.markModified('data'); - mailing.previewHtml = requestHtml; + + if (requestHtml) { + mailing.previewHtml = requestHtml; + } + await mailing.save(); const mailingForMosaico = await Mailings.findOneForMosaico( + user, query, req.user.lang ); diff --git a/packages/server/mailing/mailing.routes.js b/packages/server/mailing/mailing.routes.js index 6eb15ce9..4927bea7 100644 --- a/packages/server/mailing/mailing.routes.js +++ b/packages/server/mailing/mailing.routes.js @@ -27,6 +27,9 @@ router.post( GUARD_USER, mailings.downloadZip ); + +router.post('/download-multiple-zip', GUARD_USER, mailings.downloadMultipleZip); + router.put('/:mailingId/mosaico', GUARD_USER, mailings.updateMosaico); router.get('/:mailingId/mosaico', GUARD_USER, mailings.readMosaico); router.post('/:mailingId/duplicate', GUARD_USER, mailings.duplicate); diff --git a/packages/server/mailing/mailing.schema.js b/packages/server/mailing/mailing.schema.js index 04e2f975..f0c1155d 100644 --- a/packages/server/mailing/mailing.schema.js +++ b/packages/server/mailing/mailing.schema.js @@ -38,7 +38,6 @@ const MailingSchema = Schema( { name: { type: String, - set: normalizeString, required: true, }, previewHtml: { @@ -115,7 +114,6 @@ MailingSchema.post('find', function () { MailingSchema.plugin(mongooseHidden, { hidden: { - _id: true, __v: true, _company: true, _wireframe: true, @@ -156,6 +154,51 @@ MailingSchema.statics.findForApi = async function findForApi(query = {}) { return this.find(query, { previewHtml: 0, data: 0 }); }; +// Use aggregate so we excluse previewHtml and define another boolean variable hasPreviewHtml based on the existence of previewHtml +MailingSchema.statics.findWithHasPreview = async function findWithHasPreview( + query = {} +) { + return this.aggregate([ + { + $match: { + ...query, + }, + }, + { + $addFields: { + hasHtmlPreview: { + $not: [ + { + $not: [ + { + $ifNull: ['$previewHtml', 0], + }, + ], + }, + ], + }, + }, + }, + { + $project: { + id: '$_id', + name: 1, + group: '$_company', + templateName: '$wireframe', + templateId: '$_wireframe', + userName: '$author', + userId: '$_user', + _workspace: 1, + tags: 1, + espIds: 1, + hasHtmlPreview: 1, + updatedAt: 1, + createdAt: 1, + }, + }, + ]); +}; + // Extract used tags from creations // http://stackoverflow.com/questions/14617379/mongoose-mongodb-count-elements-in-array MailingSchema.statics.findTags = async function findTags(query = {}) { @@ -227,6 +270,7 @@ const translations = { */ MailingSchema.statics.findOneForMosaico = async function findOneForMosaico( + user, query = {}, lang = 'fr' ) { @@ -253,9 +297,21 @@ MailingSchema.statics.findOneForMosaico = async function findOneForMosaico( const mailingId = mailing._id; const groupId = group._id; const templateId = mailing._wireframe._id; + + let redirectUrl = null; + + if (user?.isAdmin) { + redirectUrl = `/groups/${groupId}?redirectTab=mailings`; + } else { + redirectUrl = mailing?._parentFolder + ? `/?fid=${mailing._parentFolder}` + : `/?wid=${mailing._workspace}`; + } + return { metadata: { id: mailingId, + groupId: groupId, templateId, name: mailing.name, hasHtmlPreview: !!mailing.previewHtml, @@ -287,7 +343,7 @@ MailingSchema.statics.findOneForMosaico = async function findOneForMosaico( placeholder: '/api/images/placeholder/', }, assets: mailing._wireframe.assets, - editorIcon: { ...config.brandOptions.editorIcon }, + editorIcon: { ...config.brandOptions.editorIcon, logoUrl: redirectUrl }, }, titleToken: 'BADSENDER Responsive Email Designer', // TODO: should be in metadata diff --git a/packages/server/mailing/mailing.service.js b/packages/server/mailing/mailing.service.js index 0b97cb6a..7b9f7ac8 100644 --- a/packages/server/mailing/mailing.service.js +++ b/packages/server/mailing/mailing.service.js @@ -34,7 +34,6 @@ const processMosaicoHtmlRender = require('../utils/process-mosaico-html-render.j const Ftp = require('./ftp-client.service.js'); const request = require('request'); -const createError = require('http-errors'); const fileManager = require('../common/file-manage.service.js'); const modelsUtils = require('../utils/model.js'); @@ -47,6 +46,7 @@ const templateService = require('../template/template.service.js'); const folderService = require('../folder/folder.service.js'); const workspaceService = require('../workspace/workspace.service.js'); +const MULTIPLE_DOWNLOAD_ZIP_NAME = 'lepatron'; module.exports = { createMailing, findMailings, @@ -63,9 +63,13 @@ module.exports = { listMailingForWorkspaceOrFolder, previewMail, downloadZip, + downloadMultipleZip, validateMailExist, processHtmlWithFTPOption, updateMailEspIds, + getMailByMailingIdAndUser, + getGroupByCompanyId, + getMailNameAndCompanyByMailingIdAndUser, }; async function listMailingForWorkspaceOrFolder({ @@ -142,13 +146,11 @@ async function updateMailEspIds(mailingId, espId) { async function findMailings(query) { const mailingQuery = applyFilters(query); - - return Mailings.find(mailingQuery, { previewHtml: 0, data: 0 }); + return Mailings.findWithHasPreview(mailingQuery); } async function findTags(query) { const mailingQuery = applyFilters(query); - return Mailings.findTags(mailingQuery); } @@ -254,24 +256,19 @@ async function createMailing(mailing) { // Process html to the final result state based on ftp async function processHtmlWithFTPOption({ mailingId, html, user }) { + logger.log('Calling processHtmlWithFTPOption'); + const mailing = await this.getMailByMailingIdAndUser({ mailingId, user }); + const { + prefix, cdnDownload, regularDownload, - prefix, - ftpEndPointProtocol, - ftpEndPoint, - ftpHost, - ftpPort, - ftpUsername, - ftpPassword, - ftpProtocol, - ftpPathOnServer, + ftpServerParams, cdnProtocol, cdnEndPoint, name, } = await extractFTPparams({ - mailingId, - user, + mailing, downloadOptions: { downLoadForCdn: false, downLoadForFtp: true, @@ -292,12 +289,9 @@ async function processHtmlWithFTPOption({ mailingId, html, user }) { cdnDownload, regularDownload, prefix, - ftpHost, - ftpPort, - ftpUsername, - ftpPassword, - ftpProtocol, - ftpPathOnServer, + name, + ftpServerParams, + doesWaitForFtp: true, }); // Add html with relatives url const processedHtml = processMosaicoHtmlRender(relativeImagesHtml); @@ -308,8 +302,7 @@ async function processHtmlWithFTPOption({ mailingId, html, user }) { cdnDownload, cdnProtocol, cdnEndPoint, - ftpEndPointProtocol, - ftpEndPoint, + ftpServerParams, processedHtml, relativesImagesNames, name, @@ -330,24 +323,18 @@ async function downloadZip({ throw new InternalServerError(ERROR_CODES.ARCHIVE_IS_NULL); } + const mailing = await this.getMailByMailingIdAndUser({ mailingId, user }); + const { cdnDownload, regularDownload, prefix, - ftpEndPointProtocol, - ftpEndPoint, - ftpHost, - ftpPort, - ftpUsername, - ftpPassword, - ftpProtocol, - ftpPathOnServer, + ftpServerParams, cdnProtocol, cdnEndPoint, name, } = await extractFTPparams({ - mailingId, - user, + mailing, downloadOptions, }); @@ -363,13 +350,9 @@ async function downloadZip({ regularDownload, archive, prefix, - ftpHost, - ftpPort, - ftpUsername, - ftpPassword, - ftpProtocol, - ftpPathOnServer, + ftpServerParams, name, + doesWaitForFtp: false, }); // ----- HTML @@ -390,8 +373,7 @@ async function downloadZip({ cdnDownload, cdnProtocol, cdnEndPoint, - ftpEndPointProtocol, - ftpEndPoint, + ftpServerParams, processedHtml, relativesImagesNames, name, @@ -429,21 +411,203 @@ async function downloadZip({ return { archive: processedImageArchive, name }; } -// Extract information related to ftp and download state based on mailing id, user and download options -async function extractFTPparams({ mailingId, user, downloadOptions }) { +// This function will be used to find out recurrent names inside a array of type Array<{mailing, name}>[] +function generateUniqueNameFromMailingList({ index, name, accumulator }) { + logger.log('Calling generateUniqueNameFromMailingList'); + // Safe exist for the recurrent function, 30 was choosen because it is not possible for the user download more than 25 zip file at the same time + let mailNameToSearch = name; + if (index > 25) { + throw new InternalServerError(ERROR_CODES.TOO_MUCH_RECURRENT_LOOP); + } + + if (index > 0) { + mailNameToSearch += ` (${index})`; + } + const foundMailsWithSameName = accumulator.filter( + (mailWithFinalName) => mailWithFinalName.name === mailNameToSearch + ); + + if (foundMailsWithSameName.length === 0) { + return mailNameToSearch; + } + + return generateUniqueNameFromMailingList({ + index: index + 1, + name, + accumulator, + }); +} + +// This will handle downloading email as a ZIP file +async function downloadMultipleZip({ + mailingIds, + archive, + downloadOptions, + user, +}) { + logger.log('Calling downloadMultipleZip'); + if (!Array.isArray(mailingIds) || mailingIds.length === 0) { + throw new InternalServerError(ERROR_CODES.MAILING_MISSING_SOURCE); + } + + if (mailingIds.length === 1) { + const firstMailing = await this.findOne(mailingIds[0]); + + return this.downloadZip({ + mailingId: firstMailing._id, + html: firstMailing.previewHtml, + archive, + user, + downloadOptions, + }); + } + + const mailingsWithNamePromises = mailingIds.map(async (mailingId) => { + const mailing = await this.getMailByMailingIdAndUser({ mailingId, user }); + return { + mailing: mailing, + name: mailing.name, + }; + }); + + const mailingsWithNames = await Promise.all(mailingsWithNamePromises); + + const mailingsWithUniqueNames = mailingsWithNames.reduce( + (accumulator, currentValue) => { + const index = 0; + const uniqueMailName = generateUniqueNameFromMailingList({ + index, + name: currentValue.name, + accumulator, + }); + accumulator.push({ + name: uniqueMailName, + mailing: currentValue.mailing, + }); + return accumulator; + }, + [] + ); + + if (!archive) { + throw new InternalServerError(ERROR_CODES.ARCHIVE_IS_NULL); + } + + if ( + !Array.isArray(mailingsWithUniqueNames) || + mailingsWithUniqueNames.length === 0 + ) { + return; + } + + for (const mailWithUniqueName of mailingsWithUniqueNames) { + const { mailing, name: uniqueName } = mailWithUniqueName; + const { + cdnDownload, + regularDownload, + prefix, + ftpServerParams, + cdnProtocol, + cdnEndPoint, + name, + } = await extractFTPparams({ + mailing, + parentContainer: MULTIPLE_DOWNLOAD_ZIP_NAME, + overrideMailName: uniqueName, + user, + downloadOptions, + }); + + const { + relativesImagesNames, + html: relativeImagesHtml, + } = await handleRelativeOrFtpImages({ + cdnDownload, + regularDownload, + html: mailing.previewHtml, + archive, + prefix, + ftpServerParams, + name, + doesWaitForFtp: false, + }); + + const processedHtml = processMosaicoHtmlRender(relativeImagesHtml); + + if (regularDownload) { + archive.append(processedHtml, { + prefix, + name: `${name}.html`, + }); + } else { + const { + htmlProcessedWithFtp, + } = await replaceImageWithFTPEndpointBaseInProcessedHtml({ + cdnDownload, + cdnProtocol, + cdnEndPoint, + ftpServerParams, + processedHtml, + relativesImagesNames, + name, + }); + // archive + + archive.append(htmlProcessedWithFtp, { + prefix, + name: `${name}.html`, + }); + } + } + + return { archive, name: MULTIPLE_DOWNLOAD_ZIP_NAME }; +} + +async function getMailByMailingIdAndUser({ mailingId, user }) { const query = modelsUtils.addGroupFilter(user, { _id: mailingId }); // mailing can come without group if created by the admin // • in order to retrieve the group, look at the wireframe const mailing = await Mailings.findOne(query) - .select({ _wireframe: 1, name: 1 }) + .select({ data: 0 }) .populate('_wireframe'); - if (!mailing) throw new createError.NotFound(); + if (!mailing) throw new NotFound(ERROR_CODES.MAILING_MISSING_SOURCE); + return mailing; +} + +async function getMailNameAndCompanyByMailingIdAndUser({ mailingId, user }) { + const query = modelsUtils.addGroupFilter(user, { _id: mailingId }); + const mailing = await Mailings.findOne(query) + .select({ name: 1, _company: 1 }) + .lean(); + if (!mailing) throw new NotFound(ERROR_CODES.MAILING_MISSING_SOURCE); + return mailing; +} + +async function getGroupByCompanyId({ companyId }) { + const group = await Groups.findById(companyId).lean(); + if (!group) throw new NotFound(ERROR_CODES.GROUP_NOT_FOUND); + return group; +} +// Extract information related to ftp and download state based on mailing id, user and download options +async function extractFTPparams({ + mailing, + downloadOptions, + parentContainer = null, + overrideMailName = null, +}) { + console.log('Calling extract ftp params'); + + if (!mailing || !mailing?._wireframe?._company || !mailing.name) + throw new NotFound(ERROR_CODES.MAILING_MISSING_SOURCE); // if (!isFromCompany(user, mailing._company)) throw new createError.Unauthorized() // group is needed to check zip format & DL configuration - const group = await Groups.findById(mailing._wireframe._company).lean(); - if (!group) throw new createError.NotFound(); + + const group = await getGroupByCompanyId({ + companyId: mailing?._wireframe?._company, + }); + if (!group) throw new NotFound(ERROR_CODES.GROUP_NOT_FOUND); const { downloadMailingWithoutEnclosingFolder, @@ -478,11 +642,13 @@ async function extractFTPparams({ mailingId, user, downloadOptions }) { throw new InternalServerError(ERROR_CODES.FTP_NOT_DEFINED_FOR_GROUP); } - const name = getName(mailing.name); + const name = getName(overrideMailName ?? mailing.name); // prefix is `zip-stream` file prefix => our enclosing folder ^_^ // !WARNING default mac unzip will always put it in an folder if more than 1 file // => test with The Unarchiver - const prefix = downloadMailingWithoutEnclosingFolder ? '' : `${name}/`; + const prefix = downloadMailingWithoutEnclosingFolder + ? '' + : `${!parentContainer ? '' : `${parentContainer}/`}${name}/`; // const $ = cheerio.load(html) @@ -497,14 +663,16 @@ async function extractFTPparams({ mailingId, user, downloadOptions }) { cdnDownload, regularDownload, prefix, - ftpEndPointProtocol, - ftpEndPoint, - ftpHost, - ftpPort, - ftpUsername, - ftpPassword, - ftpProtocol, - ftpPathOnServer, + ftpServerParams: { + ftpEndPointProtocol, + ftpEndPoint, + ftpHost, + ftpPort, + ftpUsername, + ftpPassword, + ftpProtocol, + ftpPathOnServer, + }, cdnProtocol, cdnEndPoint, name, @@ -515,12 +683,12 @@ async function replaceImageWithFTPEndpointBaseInProcessedHtml({ cdnDownload, cdnProtocol, cdnEndPoint, - ftpEndPointProtocol, - ftpEndPoint, + ftpServerParams, processedHtml, relativesImagesNames, name, }) { + const { ftpEndPointProtocol, ftpEndPoint } = ftpServerParams; const endpointBase = cdnDownload ? `${cdnProtocol}${cdnEndPoint}` : `${ftpEndPointProtocol}${ftpEndPoint}`; @@ -548,14 +716,18 @@ async function handleRelativeOrFtpImages({ regularDownload, archive, prefix, - ftpHost, - ftpPort, - ftpUsername, - ftpPassword, - ftpProtocol, - ftpPathOnServer, + ftpServerParams, name, + doesWaitForFtp, }) { + const { + ftpHost, + ftpPort, + ftpUsername, + ftpPassword, + ftpProtocol, + ftpPathOnServer, + } = ftpServerParams; if (!html) { throw new InternalServerError(ERROR_CODES.HTML_IS_NULL); } @@ -664,8 +836,11 @@ async function handleRelativeOrFtpImages({ ftpPathOnServer + (ftpPathOnServer.substr(ftpPathOnServer.length - 1) === '/' ? '' : '/') + `${name}/`; - - await ftpClient.upload(allImages, folderPath); + if (doesWaitForFtp) { + await ftpClient.upload(allImages, folderPath); + } else { + ftpClient.upload(allImages, folderPath); + } } return { relativesImagesNames, archive, html }; @@ -808,7 +983,10 @@ async function deleteMailing({ mailingId, workspaceId, parentFolderId, user }) { return deleteResponse; } + async function moveMailing(user, mailing, workspaceId, parentFolderId) { + console.log('Calling moveMailing'); + checkEitherWorkspaceOrFolderDefined(workspaceId, parentFolderId); let sourceWorkspace; let destinationWorkspace; @@ -953,12 +1131,14 @@ function applyFilters(query) { if (query.workspaceId) { return { ...mailingQueryStrictGroup, - _workspace: query.workspaceId, + _workspace: mongoose.Types.ObjectId(query.workspaceId), }; - } else { + } else if (query.parentFolderId) { return { ...mailingQueryStrictGroup, - _parentFolder: query.parentFolderId, + _parentFolder: mongoose.Types.ObjectId(query.parentFolderId), }; + } else { + return mailingQueryStrictGroup; } } diff --git a/packages/server/mailing/mosaico-editor.controller.js b/packages/server/mailing/mosaico-editor.controller.js index eb32308a..da28156e 100644 --- a/packages/server/mailing/mosaico-editor.controller.js +++ b/packages/server/mailing/mosaico-editor.controller.js @@ -36,6 +36,7 @@ async function render(req, res) { const query = modelsUtils.addGroupFilter(req.user, { _id: mailingId }); const mailingForMosaico = await Mailings.findOneForMosaico( + user, query, user.lang ); diff --git a/packages/server/mailing/send-test-mail.controller.js b/packages/server/mailing/send-test-mail.controller.js index cf6d714c..e0803772 100644 --- a/packages/server/mailing/send-test-mail.controller.js +++ b/packages/server/mailing/send-test-mail.controller.js @@ -1,16 +1,8 @@ 'use strict'; -const { createError, BadRequest } = require('http-errors'); const asyncHandler = require('express-async-handler'); -const config = require('../node.config.js'); -const { Mailings } = require('../common/models.common.js'); -const mail = require('../mailing/mail.service.js'); -const modelsUtils = require('../utils/model.js'); -const processMosaicoHtmlRender = require('../utils/process-mosaico-html-render.js'); -const isEmail = require('validator/lib/isEmail'); -const ERROR_CODES = require('../constant/error-codes.js'); -const logger = require('../utils/logger.js'); +const sendTestMailService = require('./send-test-mail.service'); module.exports = asyncHandler(sendTestMail); @@ -23,6 +15,7 @@ module.exports = asyncHandler(sendTestMail); * @apiParam {string} mailingId * * @apiParam (Body) {String} rcpt the recipient email address + * @apiParam (Body) {String} emailGroupId The id of the emails group * @apiParam (Body) {String} html the HTML output get in mosaico by `viewModel.exportHTML()` * * @apiSuccess {String} mailingList the emails to which the mailing has been sent @@ -30,35 +23,19 @@ module.exports = asyncHandler(sendTestMail); */ async function sendTestMail(req, res) { - const { user, body } = req; + const { + user, + body: { html: htmlFromEditor, rcpt, emailsGroupId }, + } = req; const { mailingId } = req.params; - const query = modelsUtils.addGroupFilter(req.user, { _id: mailingId }); - const mailing = await Mailings.findOne(query) - .select({ name: 1, _company: 1 }) - .lean(); - if (!mailing) throw new createError.NotFound(); - // TODO: add back group check - // if (!isFromCompany(user, mailing._company)) throw new createError.Unauthorized() - // body.html is the result of viewModel.exportHTML() - // • in /src/js/ext/badsender-server-storage.js - const html = processMosaicoHtmlRender(req.body.html); + const resultSendingTestMail = await sendTestMailService.sendTestMail({ + user, + rcpt, + htmlFromEditor, + emailsGroupId, + mailingId, + }); - const adresses = body.rcpt.split(';'); - for (const mail of adresses) { - if (!isEmail(mail)) { - throw new BadRequest(ERROR_CODES.EMAIL_NOT_VALID); - } - } - for (const address of adresses) { - const mailInfo = await mail.send({ - to: address, - replyTo: user?.email, - subject: config.emailOptions.testSubjectPrefix + mailing.name, - html, - }); - - logger.log('Message sent: ', mailInfo.response); - } - res.json({ mailingList: body.rcpt }); + res.json({ result: resultSendingTestMail }); } diff --git a/packages/server/mailing/send-test-mail.service.js b/packages/server/mailing/send-test-mail.service.js new file mode 100644 index 00000000..0fc95ece --- /dev/null +++ b/packages/server/mailing/send-test-mail.service.js @@ -0,0 +1,76 @@ +'use strict'; + +const config = require('../node.config.js'); +const isEmail = require('validator/lib/isEmail'); +const ERROR_CODES = require('../constant/error-codes.js'); +const logger = require('../utils/logger.js'); +const { onlyUnique } = require('../utils/array.js'); +const mailingService = require('./mailing.service.js'); +const emailsGroupService = require('../emails-group/emails-group.service'); +const processMosaicoHtmlRender = require('../utils/process-mosaico-html-render.js'); + +const { BadRequest } = require('http-errors'); +const mail = require('../mailing/mail.service.js'); + +module.exports = { + sendTestMail, +}; + +async function sendTestMail({ + mailingId, + emailsGroupId, + rcpt, + htmlFromEditor, + user, +}) { + const mailing = await mailingService.getMailNameAndCompanyByMailingIdAndUser({ + mailingId, + user, + }); + + // TODO: add back group check + // if (!isFromCompany(user, mailing._company)) throw new createError.Unauthorized() + + // body.html is the result of viewModel.exportHTML() + // • in /src/js/ext/badsender-server-storage.js + const html = processMosaicoHtmlRender(htmlFromEditor); + + let adresses = rcpt.split(';'); + + if (!!emailsGroupId && typeof emailsGroupId !== 'undefined') { + const emailGroups = await emailsGroupService.getEmailsGroup({ + userGroupId: user.group?.id, + emailsGroupId, + }); + + if (emailGroups && emailGroups.emails) { + adresses = [...adresses, ...emailGroups.emails.split(';')]; + } + } + + adresses = adresses.filter(onlyUnique).filter((email) => !!email); + + for (const mail of adresses) { + if (!isEmail(mail)) { + throw new BadRequest(ERROR_CODES.EMAIL_NOT_VALID); + } + } + + for (const address of adresses) { + try { + const mailInfo = await mail.send({ + to: address, + replyTo: user?.email, + subject: config.emailOptions.testSubjectPrefix + mailing.name, + html, + }); + + logger.log('Message sent: ', mailInfo.response); + } catch (error) { + logger.error('Error occured while sent email: ', address); + logger.error('Error ', error.message); + } + } + + return adresses; +} diff --git a/packages/server/node.config.js b/packages/server/node.config.js index ffbe5a15..0243ff12 100644 --- a/packages/server/node.config.js +++ b/packages/server/node.config.js @@ -42,7 +42,7 @@ const config = rc('lepatron', { }, emailOptions: { from: 'LePatron.email local test ', - passwordSubjectPrefix: 'LePatron.email', + passwordSubjectPrefix: 'Email builder LePatron', // last space is needed testSubjectPrefix: '[LePatron.email] ', }, diff --git a/packages/server/routes-oauth2.js b/packages/server/routes-oauth2.js index b76325fc..8d30ab27 100755 --- a/packages/server/routes-oauth2.js +++ b/packages/server/routes-oauth2.js @@ -81,7 +81,6 @@ server.grant( oauth2orize.grant.token(async (client, user, ares, done) => { // const token = getUid() console.log('grant token'); - console.log({ client, user }); try { const token = OAuthTokens.create({ userId: user.id, diff --git a/packages/server/user/user.controller.js b/packages/server/user/user.controller.js index 31fb584d..334d4211 100644 --- a/packages/server/user/user.controller.js +++ b/packages/server/user/user.controller.js @@ -46,7 +46,6 @@ async function list(req, res) { async function getUsersByGroupId(req, res) { const { user: connectedUser } = req; - console.log({ connectedUser }); const users = await userService.findByGroupId(connectedUser?.group?.id); res.json(users); } diff --git a/packages/server/user/user.schema.js b/packages/server/user/user.schema.js index db041baa..c6adbcf9 100644 --- a/packages/server/user/user.schema.js +++ b/packages/server/user/user.schema.js @@ -152,7 +152,7 @@ UserSchema.methods.resetPassword = async function resetPassword(type, lang) { const resetUrl = `http://${config.host}/account/${updatedUser.email}/password/${user.token}`; await mail.send({ to: updatedUser.email, - subject: `${config.emailOptions.passwordSubjectPrefix} – password reset`, + subject: `${config.emailOptions.passwordSubjectPrefix} – Password reset`, text: `here is the link to enter your new password ${resetUrl}`, html: tmpReset( getTemplateData('reset-password', lang, { @@ -216,15 +216,32 @@ function getTemplateData(templateName, lang, additionalDatas) { const i18n = { common: { fr: { - contact: 'Contacter Badsender : ', - or: 'ou', - // social: `Badsender sur les réseaux sociaux :`, - social: 'Badsender sur les réseaux sociaux :', + baseline: 'L\'EMAILING SUR MESURE', + footerBaseline1: 'Pour des emails sur mesure', + footerBaseline2: 'et modulables', + rgpd1: + 'Conformément au règlement européen pour la protection des données personnelles (RGPD) et, en France, à la loi "informatique et libertés", vous bénéficiez notamment d\'un droit d\'accès, de rectification et de suppression des données personnelles vous concernant. Pour en savoir davantage sur tous vos droits et les conditions dans lesquelles Badsender traite vos données personnelles, nous vous invitons à prendre connaissance de', + rgpd2: + 'Pour exercer vos droits ou pour toute question, nous vous remercions de nous contacter à l\'adresse suivante :', + rgpdUrl: 'notre Politique de confidentialité', + zeroCarbon: + 'N\'oubliez pas de détruire ce message une fois que vous l\'aurez consulté. Toutes nos bonnes pratiques pour un emailing + vert sont à disposition ', + zeroCarbonLink: 'en suivant ce lien', + legals: 'Badsender SASU - SIRET 81310812300015', }, en: { - contact: 'contact Badsender: ', - or: 'or', - social: 'Badsender on social networks:', + baseline: 'TAILOR-MADE EMAILING', + footerBaseline1: 'For tailor-made emails', + footerBaseline2: 'and scalable', + rgpd1: + 'In accordance with the European regulation for the protection of personal data (GDPR) and, in France, with the law "informatique et liberté", you have the right to access, rectify and delete your personal data. To learn more about all your rights and the conditions under which Badsender processes your personal data, we invite you to read', + rgpd2: + 'To exercise your rights or if you have any questions, please contact us at the following address:', + rgpdUrl: 'our Privacy Policy', + zeroCarbon: + 'Don\'t forget to delete this message once you\'ve viewed it. All our best practices for a green emailing are available ', + zeroCarbonLink: 'by following this link', + legals: 'Badsender SASU - SIRET 81310812300015', }, }, 'reset-password': { diff --git a/packages/server/utils/array.js b/packages/server/utils/array.js new file mode 100644 index 00000000..cb736ac2 --- /dev/null +++ b/packages/server/utils/array.js @@ -0,0 +1,5 @@ +function onlyUnique(value, index, self) { + return self.indexOf(value) === index; +} + +module.exports = { onlyUnique }; diff --git a/packages/server/utils/model.js b/packages/server/utils/model.js index 0531e0e3..a1c42258 100644 --- a/packages/server/utils/model.js +++ b/packages/server/utils/model.js @@ -1,5 +1,7 @@ 'use strict'; +const mongoose = require('mongoose'); + const SPACE_TYPE = require('../constant/space-type'); module.exports = { @@ -43,7 +45,7 @@ function addGroupFilter(user, filter) { // Admin can't get content with a group function addStrictGroupFilter(user, filter) { const group = user.isAdmin ? { $exists: false } : user.group.id; - filter._company = group; + filter._company = mongoose.Types.ObjectId(group); return filter; } diff --git a/packages/ui/components/group/emails-groups-tab.vue b/packages/ui/components/group/emails-groups-tab.vue new file mode 100644 index 00000000..e3438feb --- /dev/null +++ b/packages/ui/components/group/emails-groups-tab.vue @@ -0,0 +1,138 @@ + + + diff --git a/packages/ui/components/group/form-emails-group.vue b/packages/ui/components/group/form-emails-group.vue new file mode 100644 index 00000000..97f1a31f --- /dev/null +++ b/packages/ui/components/group/form-emails-group.vue @@ -0,0 +1,105 @@ + + + diff --git a/packages/ui/components/group/menu.vue b/packages/ui/components/group/menu.vue index 4fc98fb4..6b861965 100644 --- a/packages/ui/components/group/menu.vue +++ b/packages/ui/components/group/menu.vue @@ -24,6 +24,9 @@ export default { newProfileHref() { return `/groups/${this.groupId}/new-profile`; }, + newEmailsGroup() { + return `/groups/${this.groupId}/new-emails-group`; + }, }, }; @@ -97,6 +100,16 @@ export default { {{ $t('global.newProfile') }} + + + mdi-email-plus + + + + {{ $t('global.newEmailsGroup') }} + + + diff --git a/packages/ui/components/modal-confirm.vue b/packages/ui/components/modal-confirm.vue index 4695605d..01889993 100644 --- a/packages/ui/components/modal-confirm.vue +++ b/packages/ui/components/modal-confirm.vue @@ -8,6 +8,7 @@ export default { }, title: { type: String, default: '' }, modalWidth: { type: String, default: '500' }, + displaySubmitButton: { type: Boolean, default: true }, actionLabel: { type: String, default: '' }, actionButtonColor: { type: String, default: 'primary' }, }, @@ -20,6 +21,7 @@ export default { }, close() { this.show = false; + this.$emit('close'); }, action() { this.close(); @@ -54,7 +56,11 @@ export default { {{ $t('global.cancel') }} - + {{ actionLabel }} diff --git a/packages/ui/helpers/api-routes.js b/packages/ui/helpers/api-routes.js index 2084bdaf..e1affd56 100644 --- a/packages/ui/helpers/api-routes.js +++ b/packages/ui/helpers/api-routes.js @@ -132,6 +132,10 @@ export function moveManyMails() { export function preview(mailingId) { return `/mailings/${mailingId}/preview`; } + +export function downloadMultipleMails() { + return '/mailings/download-multiple-zip'; +} /// /// // IMAGES /// /// @@ -222,3 +226,15 @@ export function getTableTargetList() { export function getEntitiesList() { return '/profiles/actito-entities-list'; } + +/// /// +// Email groups +/// /// + +export function getEmailsGroups() { + return '/emails-groups'; +} + +export function getEmailsGroup(emailsGroupId) { + return `/emails-groups/${emailsGroupId}`; +} diff --git a/packages/ui/helpers/constants/mails.js b/packages/ui/helpers/constants/mails.js index 840280a2..eac1e156 100644 --- a/packages/ui/helpers/constants/mails.js +++ b/packages/ui/helpers/constants/mails.js @@ -6,6 +6,8 @@ export const ACTIONS = { ADD_TAGS: 'ADD_TAGS', MOVE_MAIL: 'MOVE_MAIL', PREVIEW: 'PREVIEW', + DOWNLOAD: 'DOWNLOAD', + DOWNLOAD_FTP: 'DOWNLOAD_FTP', }; export const ACTIONS_DETAILS = { @@ -38,4 +40,12 @@ export const ACTIONS_DETAILS = { text: 'global.preview', icon: 'visibility', }, + [ACTIONS.DOWNLOAD]: { + text: 'global.download', + icon: 'download', + }, + [ACTIONS.DOWNLOAD_FTP]: { + text: 'global.downloadFtp', + icon: 'mdi-cloud-download', + }, }; diff --git a/packages/ui/helpers/locales/en.js b/packages/ui/helpers/locales/en.js index 7a62455f..dc1d6ef6 100644 --- a/packages/ui/helpers/locales/en.js +++ b/packages/ui/helpers/locales/en.js @@ -3,25 +3,25 @@ export default { updated: 'Updated', created: 'Created', deleted: 'Deleted', - usersFetchError: 'Unable to access users\' list', + usersFetchError: 'Unable to access users\' list :(', emailSent: 'An email was sent', }, global: { errors: { - errorOccured: 'An error has occurred', + errorOccured: 'Oops! An error has occurred :(', required: 'This field is required', userRequired: 'A user is required', - entityRequired: 'Entity is required', - targetTableRequired: 'Target table is required', + entityRequired: 'An entity is required', + targetTableRequired: 'A target table is required', nameRequired: 'A name is required', - apiKeyInvalid: 'Api key invalid', - supportedLanguageIsRequired: 'Language is required', - apiKeyRequired: 'Api Key is Required', - senderNameRequired: 'Sender name is Required', - senderMailRequired: 'Sender mail is Required', + apiKeyInvalid: 'API key invalid', + supportedLanguageIsRequired: 'A language is required', + apiKeyRequired: 'An API Key is Required', + senderNameRequired: 'A Sender name is Required', + senderMailRequired: 'A Sender email is Required', WORKSPACE_ALREADY_EXISTS: 'A workspace with this name already exists', - FORBIDDEN_WORKSPACE_CREATION: - 'You don\'t have the rights to create this workspace', + FORBIDDEN_WORKSPACE_CREATION: 'You don\'t have the rights to create this workspace', + emailsGroupExist: 'The name of this test list already exist', password: { error: { nouser: 'User not found', @@ -30,17 +30,17 @@ export default { }, login: { error: { - internal: 'An error occurred', + internal: 'Oops! An error occurred :(', }, }, }, - apiKey: 'Api key', + add: 'Add', + apiKey: 'API key', entity: 'Entity', encodingType: 'Encoding type', targetTable: 'Target table', supportedLanguage: 'Language', profileName: 'ESP profile name', - add: 'Add', teams: 'Workspaces', addTags: 'Add tags', copyMail: 'Copy', @@ -51,7 +51,7 @@ export default { moveManyMail: 'Move', newTemplate: 'Add a template', newFolder: 'Add a folder', - parentLocation: 'Parent location', + parentLocation: 'Parent folder location', template: 'Template | Templates', mailing: 'Email | Emails', newMailing: 'Add an email', @@ -60,16 +60,17 @@ export default { profile: 'ESP', user: 'User | Users', newUser: 'Add a user', + newEmailsGroup: 'Add a test list', newTeam: 'Add a workspace', newTag: 'Add a tag', - backToMails: 'Back to mails', + backToMails: 'Back to emails', backToGroups: 'Back to groups', group: 'Group | Groups', workspaces: 'Workspaces', newGroup: 'Add a group', workspace: 'Workspace', newWorkspace: 'Add a workspace', - newMail: 'New mail', + newMail: 'New email', image: 'Image | Images', actions: 'Actions', save: 'Save', @@ -87,7 +88,7 @@ export default { settings: 'Settings', download: 'Download', preview: 'Preview', - previewMailAlt: 'Preview of the email template', + previewMailAlt: 'Preview of the email', newPreview: 'Create a preview', name: 'Name', description: 'Description', @@ -104,6 +105,11 @@ export default { updatedAt: 'Updated at', edit: 'Edit', move: 'Move', + emailsGroups: 'Test lists', + emailsGroupsEmpty: 'No test list available', + editEmailsGroup: 'Edit test list', + continue: 'Continue', + downloadFtp: 'Download FTP', }, layout: { logout: 'Logout', @@ -118,9 +124,6 @@ export default { defaultWorkspace: { label: 'Default workspace\'s name', }, - exportFtp: 'Export images on an FTP', - exportCdn: 'Export images on a CDN', - enable: 'Enable', status: { label: 'Status', demo: 'Demo', @@ -128,17 +131,20 @@ export default { active: 'Active', requiredValidationMessage: 'A status is required', }, + exportFtp: 'Export images on an FTP', + exportCdn: 'Export images on a CDN', + enable: 'Enable', ftpProtocol: 'FTP protocol', host: 'Host', username: 'Username', port: 'Port', path: 'Folder\'s path', - endpoint: 'Images root\'url', + httpProtocol: 'Protocole HTTP', + endpoint: 'Images root\'URL', editorLabel: 'Button label', entryPoint: 'Entry point', issuer: 'Issuer', - userHasAccessToAllWorkspaces: - 'Regular users have access to all workspaces', + userHasAccessToAllWorkspaces: 'Give access to all workspaces to regular users', }, template: { meta: 'Meta', @@ -168,9 +174,23 @@ export default { noPassword: 'Disabled cause of SAML Authentication', }, }, + emailsGroup: { + emails: 'Emails', + emailsPlaceholder: 'Example: email.01@domain.com;email.02@domain.com;', + errors: { + name: { + required: 'the list name is required', + }, + emails: { + required: 'You have to fill at least one email address', + emailsValid: 'There are invalid email addresses', + }, + }, + deleteNotice: 'You are about to delete the test list. This action can\'t be undone.', + deleteSuccess: 'Test list deleted', + }, workspace: { - checkBoxError: - 'I understand that workspace\'s emails and folders will be removed too', + checkBoxError: 'I understand that workspace\'s emails and folders will be removed too', inputError: 'The name is required', inputMaxLength: 'Name must not exceed 70 characters', moveFolderConfirmationMessage: 'Please choose the destination', @@ -178,10 +198,10 @@ export default { profile: { errors: { email: { - valid: 'A valid email is required', + valid: 'A valid email address is required', }, apiKey: { - unauthorized: 'The API key you provided is not authorized', + unauthorized: 'The provided API key is not allowed :(', }, }, }, @@ -191,22 +211,17 @@ export default { informations: 'Information', }, mailingTab: { - confirmationField: 'Type the mailing name to confirm', - deleteWarningMessage: - 'You are about to delete the "{name}" mailing. This action can\'t be undone.', - deleteSuccessful: 'Mailing deleted', - deleteFolderWarning: - 'You are about to delete the "{name}" folder. This action can\'t be undone.', - deleteFolderNotice: - 'Emails and folders contained in the folder will also be deleted', + confirmationField: 'Type the email name to confirm', + deleteWarningMessage: 'You are about to delete the email: {name}. This action can\'t be undone.', + deleteSuccessful: 'Email deleted', + deleteFolderWarning: 'You are about to delete the folder: {name} folder. This action can\'t be undone.', + deleteFolderNotice: 'Emails and folders contained in this folder will also be deleted', deleteFolderSuccessful: 'Folder deleted', }, workspaceTab: { confirmationField: 'Type the workspace name to confirm', - deleteNotice: - 'Emails and folders contained in the workspace will also be deleted', - deleteWarningMessage: - 'You are about to delete the "{name}" workspace. This action can\'t be undone.', + deleteNotice: 'Emails and folders contained in the workspace will also be deleted', + deleteWarningMessage: 'You are about to delete the workspace: "{name}. This action can\'t be undone.', deleteSuccessful: 'Workspace deleted', }, }, @@ -215,26 +230,28 @@ export default { label: 'Transfer email', success: 'Email transferred', }, + list: '🔎 Search in the email list', creationNotice: 'Click on any of above templates to create email', - list: 'Emails list', filters: { createdBetween: 'Created between', updatedBetween: 'Updated between', - and: 'And', + and: 'and', }, - deleteManySuccessful: 'Mailings deleted', - deleteConfirmationMessage: - 'You are about to delete the selected mailings. This action can\'t be undone.', + deleteManySuccessful: 'Emails deleted', + deleteConfirmationMessage: 'You are about to delete all the selected emails. This action can\'t be undone.', + downloadManyMailsWithoutPreview: 'This email list does not contain previews so they will not be downloaded. Do you want to continue anyway?', + downloadSingleMailWithoutPreview: 'Cannot download an email without a preview. Please open the email at least once to generate one.', duplicate: 'Duplicate email', - duplicateNotice: 'Are you sure to duplicate {name} ?', + duplicateNotice: 'Are you sure to duplicate: {name} ?', name: 'Email name', - errorPreview: 'No preview available for this email.', - subErrorPreview: - 'A preview will be generated when opening the mail editor.', + errorPreview: 'No preview available for this email', + subErrorPreview: 'A preview will be generated when opening the email editor', rename: 'Rename email', selectedCount: '{count} email selected | {count} emails selected', selectedShortCount: '{count} email| {count} emails', deleteCount: 'Delete {count} email | Delete {count} emails', + downloadCount: 'Download {count} email | Download {count} emails', + downloadFtpCount: 'Download ftp {count} email | Download ftp {count} emails', moveCount: 'Move {count} email | Move {count} emails', deleteNotice: 'This will definitely remove:', copyMailConfirmationMessage: 'Please choose the location of the new copy', @@ -242,6 +259,8 @@ export default { moveMailConfirmationMessage: 'Please choose the destination', moveMailSuccessful: 'Email moved', moveManySuccessful: 'Emails moved', + downloadManySuccessful: 'Emails download complete', + downloadMailSuccessful: 'Email download complete', editTagsSuccessful: 'Tags updated', }, template: { @@ -252,12 +271,11 @@ export default { preview: 'Download template', removeImages: 'Delete all images', imagesRemoved: 'Images deleted', - deleteNotice: - 'Deleting a template will also remove every mailings using this one', + deleteNotice: 'Deleting a template will also remove every emails using this one', }, tags: { list: 'Tags\' list', - new: 'New tags', + new: 'New tag', handle: 'Handle tags', }, users: { @@ -275,14 +293,20 @@ export default { disableNotice: 'Are you sure you want to disable', passwordNotice: { reset: 'Are you sure you want to reset the password of', - send: 'Are you sure you want to send the password mail to', - resend: 'Are you sure you want to resend the password mail to', + send: 'Are you sure you want to send the password email to', + resend: 'Are you sure you want to resend the password email to', }, - email: 'Email', + email: 'Email address', lang: 'Language', details: 'Details', role: 'Role', }, + workspaces: { + name: 'Name', + description: 'Descritpion', + members: 'Users', + userIsGroupAdmin: 'The user is a group admin', + }, profiles: { name: 'Name', type: 'Type', @@ -294,22 +318,14 @@ export default { edit: 'Edit', delete: 'Delete', contentSendType: 'The content type', - warningNoFTP: - 'You cannot add profile without having configured FTP server.', + warningNoFTP: 'You cannot add profile without having configured FTP server.', emptyState: 'No profile available', - deleteWarningMessage: - 'You are about to delete the "{name}" profile. This action can\'t be undone.', - }, - workspaces: { - name: 'Name', - description: 'Descritpion', - members: 'Members', - userIsGroupAdmin: 'The user is a group admin', + deleteWarningMessage: 'You are about to delete the profile: "{name}". This action can\'t be undone.', }, folders: { name: 'Folder name', nameUpdated: 'Folder renamed', - renameTitle: 'Rename folder {name} ', + renameTitle: 'Rename folder {name}', rename: 'Rename', created: 'Folder created', conflict: 'Folder already exists', @@ -324,7 +340,7 @@ export default { status: 'Status', }, users: { - passwordMail: 'Password\' mail', + passwordMail: 'Password\' email', }, templates: { markup: 'Markup?', diff --git a/packages/ui/helpers/locales/fr.js b/packages/ui/helpers/locales/fr.js index 615fa8e9..5157633d 100644 --- a/packages/ui/helpers/locales/fr.js +++ b/packages/ui/helpers/locales/fr.js @@ -1,36 +1,36 @@ export default { snackbars: { updated: 'Mis à jour', - created: 'Crée', + created: 'Créé', deleted: 'Supprimé', - usersFetchError: 'Impossible d\'accéder à la liste des utilisateurs', - emailSent: 'Un email a été envoyé', + usersFetchError: 'Impossible d\'accéder à la liste des utilisateurs :(', + emailSent: 'L\'email a bien été envoyé', }, global: { errors: { - errorOccured: 'Une erreur est survenue', - required: 'Ce champ est requis', - userRequired: 'Un utilisateur est requis', - nameRequired: 'Un nom est requis', - entityRequired: 'L\'entité est requise', - targetTableRequired: 'La table cible est requise', - supportedLanguageIsRequired: 'La langue est requise', - apiKeyInvalid: 'La clé API invalide', - apiKeyRequired: 'La clé api est requise', - senderNameRequired: 'Nom de l\'expéditeur est requis', - senderMailRequired: 'Mail de l\'expéditeur est requis', - WORKSPACE_ALREADY_EXISTS: 'Un workspace avec ce nom existe déjà', - FORBIDDEN_WORKSPACE_CREATION: - 'Vous n\'avez pas les droits pour créer ce workspace', + errorOccured: 'Oups ! Une erreur est survenue :(', + required: 'Ce champ est obligatoire', + userRequired: 'Veuillez saisir un utilisateur', + nameRequired: 'Veuillez saisir un nom', + entityRequired: 'Veuillez choisir une entité', + targetTableRequired: 'Veuillez choisir une table cible', + supportedLanguageIsRequired: 'Veuillez définir La langue', + apiKeyInvalid: 'La clé API est invalide', + apiKeyRequired: 'Veuillez saisir une clé API', + senderNameRequired: 'Veuillez saisir un nom d\'expéditeur', + senderMailRequired: 'Veuillez saisir l\'adresse email de l\'expéditeur', + WORKSPACE_ALREADY_EXISTS: 'Un workspace avec le même nom existe déjà', + FORBIDDEN_WORKSPACE_CREATION: 'Vous n\'avez pas les droits pour créer ce workspace', + emailsGroupExist: 'Ce nom de liste de test est déjà utilisé', password: { error: { - nouser: 'Utilisateur introuvable', - incorrect: 'Identifiants incorrects', + nouser: 'Cet utilisateur est introuvable', + incorrect: 'Les identifiants saisis sont incorrects', }, }, login: { error: { - internal: 'Une erreur est survenue', + internal: 'Oups ! Une erreur est survenue :(', }, }, }, @@ -39,7 +39,7 @@ export default { entity: 'Entité', encodingType: 'Type d\'encodage', targetTable: 'Table cible', - supportedLanguage: 'La langue', + supportedLanguage: 'Langue', profileName: 'Nom du profil ESP', addTags: 'Ajouter des labels', copyMail: 'Copier', @@ -51,25 +51,26 @@ export default { teams: 'Workspaces', newTemplate: 'Ajouter un template', newFolder: 'Ajouter un dossier', - parentLocation: 'Emplacement du parent', + parentLocation: 'Emplacement du dossier parent', template: 'Template | Templates', mailing: 'Email | Emails', newMailing: 'Ajouter un email', user: 'Utilisateur | Utilisateurs', newUser: 'Ajouter un utilisateur', + newEmailsGroup: 'Ajouter une liste de test', newTeam: 'Ajouter un workspace', newProfile: 'Ajouter un profil ESP', editProfile: 'Éditer le profil ESP {name}', profile: 'ESP', - newTag: 'Ajouter un tag', - backToMails: 'Retour aux emails', - backToGroups: 'Retour aux groupes', + newTag: 'Ajouter un label', + backToMails: 'Retourner aux emails', + backToGroups: 'Retourner aux groupes', group: 'Groupe | Groupes', workspaces: 'Espaces de travail', newGroup: 'Ajouter un groupe', workspace: 'Workspace', newWorkspace: 'Ajouter un workspace', - newMail: 'Nouvel email', + newMail: 'Créer un email', image: 'Image | Images', actions: 'Actions', save: 'Enregistrer', @@ -87,7 +88,7 @@ export default { show: 'Visualiser', download: 'Télécharger', preview: 'Prévisualiser', - previewMailAlt: 'Aperçu du template du mail', + previewMailAlt: 'Aperçu de l\'email', newPreview: 'Créer une prévisulisation', name: 'Nom', description: 'Description', @@ -104,6 +105,11 @@ export default { updatedAt: 'Mis à jour le', edit: 'Modifier', move: 'Déplacer', + emailsGroups: 'Listes de test', + emailsGroupsEmpty: 'Aucune liste de test disponible', + editEmailsGroup: 'Modifier la liste de test', + continue: 'Continuer', + downloadFtp: 'Télécharger FTP', }, layout: { logout: 'Déconnexion', @@ -120,10 +126,10 @@ export default { }, status: { label: 'Statut', - demo: 'Demo', - inactive: 'Inactive', - active: 'Active', - requiredValidationMessage: 'Le statut est requis', + demo: 'Démo', + inactive: 'Inactif', + active: 'Actif', + requiredValidationMessage: 'Veuillez choisir un statut', }, exportFtp: 'Exporter les images sur un FTP', exportCdn: 'Exporter les images sur un CDN', @@ -134,12 +140,11 @@ export default { port: 'Port', path: 'Chemin du dossier', httpProtocol: 'Protocole HTTP', - endpoint: 'Url racine des images', + endpoint: 'URL racine des images', editorLabel: 'Libellé du bouton', entryPoint: 'Point d\'entrée', issuer: 'Issuer', - userHasAccessToAllWorkspaces: - 'Les utilisateurs réguliers ont accès à tous les workspaces.', + userHasAccessToAllWorkspaces: 'Donner accès à tous les workspaces aux utilisateurs standards', }, template: { meta: 'Meta', @@ -150,35 +155,53 @@ export default { passwordConfirm: 'Confirmation du mot de passe', passwordReset: 'Réinitialisation du mot de passe', login: 'Connexion', - adminLogin: 'Connexion Admin', + adminLogin: 'Connexion administrateur', sendLink: 'Envoyer le lien de réinitialisation', forgottenPassword: 'Mot de passe oublié ?', validate: 'Valider', errors: { password: { - required: 'Un mot de passe est requis', - confirm: 'Vous devez confirmer votre mot de passe', - same: 'Vos mots de passe sont différents', + required: 'Veuillez saisir un mot de passe', + confirm: 'Veuillez confirmer votre mot de passe', + same: 'Les mots de passe saisis sont différents', }, email: { - required: 'Un email est requis', - valid: 'Un email valide est requis', + required: 'Veuillez saisir une adresse email', + valid: 'L\'adresses email doit être valide', }, }, + tooltip: { + noPassword: 'Désactivé à cause de l\'authenfication SAML', + }, + }, + emailsGroup: { + emails: 'Emails', + emailsPlaceholder: 'Exemple: email.01@domaine.com;email.02@domaine.com;', + errors: { + name: { + required: 'Veuillez saisir un nom de liste de test', + }, + emails: { + required: 'Veuillez saisir au moins une adresse email', + emailsValid: 'Il semblerait qu\'il y ait des adresses emails non valides', + }, + }, + deleteNotice: 'Vous êtes sur le point de supprimer cette liste de test. Cette action est irréversible.', + deleteSuccess: 'La liste de test a bien été supprimé', }, workspace: { - checkBoxError: - 'Je comprends que les emails et les dossiers contenus dans le workspace seront aussi supprimés', - inputError: 'Le nom est requis', + checkBoxError: 'Je comprends que les emails et les dossiers contenus dans le workspace seront aussi supprimés', + inputError: 'Veuillez saisir un nom', inputMaxLength: 'Le nom ne doit pas dépasser 70 caractères', + moveFolderConfirmationMessage: 'Veuillez choisir la destination', }, profile: { errors: { email: { - valid: 'Un email valide est requis', + valid: 'Veuillez saisir une adresse email valide', }, apiKey: { - unauthorized: 'La clé API que vous avez fournie n\'est pas autorisée', + unauthorized: 'La clé API fournie n\'est pas autorisée :(', }, }, }, @@ -188,61 +211,57 @@ export default { informations: 'Informations', }, mailingTab: { - confirmationField: 'Tapez le nom de l\'email pour confirmer', - deleteWarningMessage: - 'Vous êtes sur le point de supprimer l\'email "{name}". Cette action est irréversible.', - deleteSuccessful: 'Email supprimé', - deleteFolderWarning: - 'Vous êtes sur le point de supprimer le dossier "{name}". Cette action est irréversible.', - deleteFolderNotice: - 'Les emails et les dossiers que contient le dossier seront supprimés aussi', - deleteFolderSuccessful: 'Dossier supprimé', + confirmationField: 'Veuillez saisir le nom de l\'email pour confirmer', + deleteWarningMessage: 'Vous êtes sur le point de supprimer l\'email : {name}. Cette action est irréversible.', + deleteSuccessful: 'L\'email a bien été supprimé', + deleteFolderWarning: 'Vous êtes sur le point de supprimer le dossier : {name}. Cette action est irréversible.', + deleteFolderNotice: 'Les emails et dossiers contenus dans ce dossier seront également supprimés.', + deleteFolderSuccessful: 'Le dossier a bien été supprimé', }, workspaceTab: { - confirmationField: 'Tapez le nom du workspace pour confirmer', - deleteWarningMessage: - 'Vous êtes sur le point de supprimer le workspace "{name}". Cette action est irréversible.', - deleteNotice: - 'Les emails et les dossiers que contient le workspace seront supprimés aussi', - deleteSuccessful: 'Workspace supprimé', + confirmationField: 'Veuillez saisir le nom du workspace pour confirmer', + deleteWarningMessage: 'Vous êtes sur le point de supprimer le workspace : {name}. Cette action est irréversible.', + deleteNotice: 'Les emails et dossiers contenus dans ce workspace seront également supprimés.', + deleteSuccessful: 'Le workspace a bien été supprimé', }, }, mailings: { transfer: { label: 'Transférer l\'email', - success: 'Email transféré', + success: 'L\'email a bien été transféré', }, - creationNotice: - 'Cliquez sur l\'un des templates ci-dessous pour créer un nouvel email', - list: 'Liste des emails', + creationNotice: 'Veuillez choisir un template afin de créer un nouvel email', + list: '🔎 Rechercher dans la liste d\'emails', filters: { - createdBetween: 'Crée entre le', + createdBetween: 'Créé entre le', updatedBetween: 'Mis à jour entre le', - and: 'Et le', + and: 'et le', }, - deleteManySuccessful: 'Emails supprimés', - deleteConfirmationMessage: - 'Vous êtes sur le point de supprimer les emails sélectionnés. Cette action est irréversible.', + deleteManySuccessful: 'Les emails ont bien été supprimés', + deleteConfirmationMessage: 'Vous êtes sur le point de supprimer l\'ensemble des emails sélectionnés. Cette action est irréversible.', duplicate: 'Dupliquer l\'email', - duplicateNotice: - 'Êtes-vous sûr de vouloir dupliquer {name} ?', + duplicateNotice: 'Êtes-vous sûr de vouloir dupliquer l\'email : {name} ?', name: 'Nom de l\'email', - errorPreview: 'Pas d’aperçu disponible pour ce mail.', - subErrorPreview: - 'Un aperçu sera généré lors de l\'ouverture de l\'éditeur du mail.', + downloadManyMailsWithoutPreview: 'Cette liste d\'emails ne contient pas de prévisualisation, ils ne pourront pas tous être téléchargés. Voulez vous continuer quand même ?', + downloadSingleMailWithoutPreview: 'Impossible de télécharger un email sans aperçu. Veuillez ouvrir l\'email dans l\'éditeur pour en générer un.', + errorPreview: 'Pas d’aperçu disponible pour cet email', + subErrorPreview: 'L\'aperçu sera généré lors de l\'ouverture dans l\'éditeur', rename: 'Renommer l\'email', selectedCount: '{count} email sélectionné | {count} emails sélectionnés', selectedShortCount: '{count} email | {count} emails', deleteCount: 'Supprimer {count} email | Supprimer {count} emails', + downloadCount: 'Télécharger {count} email | Télécharger {count} emails', + downloadFtpCount: 'Télécharger en FTP {count} email | Télécharger en FTP {count} emails', moveCount: 'Déplacer {count} email | Déplacer {count} emails', - deleteNotice: 'Cela supprimera définitivement:', - copyMailConfirmationMessage: - 'Veuillez choisir l\'emplacement de la nouvelle copie', - copyMailSuccessful: 'Email copié', + deleteNotice: 'Cela supprimera définitivement :', + copyMailConfirmationMessage: 'Veuillez choisir l\'emplacement de la nouvelle copie', + copyMailSuccessful: 'L\'email a bien été copié', moveMailConfirmationMessage: 'Veuillez choisir la destination', - moveMailSuccessful: 'Email déplacé', - moveManySuccessful: 'Emails déplacés', - editTagsSuccessful: 'Labels mis à jour', + moveMailSuccessful: 'L\'email a bien été déplacé', + downloadManySuccessful: 'Les emails ont bien été téléchargés', + downloadMailSuccessful: 'L\'email a bien été téléchargé', + moveManySuccessful: 'Les emails ont bien été déplacés', + editTagsSuccessful: 'Les labels ont bien été mis à jour', }, template: { noId: 'Aucun ID', @@ -250,10 +269,9 @@ export default { markup: 'Markup', download: 'Télécharger le markup', preview: 'Prévisualiser le template', - removeImages: 'Supprimer toute les images', - imagesRemoved: 'Images supprimées', - deleteNotice: - 'Supprimer un template supprimera aussi tout les emails utilisant celui-ci', + removeImages: 'Supprimer toutes les images', + imagesRemoved: 'Les images ont bien été supprimées', + deleteNotice: 'Supprimer un template supprimera aussi tout les emails utilisant ce template', }, tags: { list: 'Liste des labels', @@ -278,18 +296,15 @@ export default { send: 'Êtes-vous sûr de vouloir envoyer l\'email de mot de passe à', resend: 'Êtes-vous sûr de vouloir renvoyer l\'email de mot de passe à', }, - email: 'Email', + email: 'Adressse email', lang: 'Langue', details: 'Informations', role: 'Rôle', - tooltip: { - noPassword: 'Désactivé à cause de l\'authenfication SAML', - }, }, workspaces: { name: 'Nom', description: 'Descritpion', - members: 'Membres', + members: 'Utilisateurs', userIsGroupAdmin: 'L\'utilisateur est un administrateur du groupe', }, profiles: { @@ -298,27 +313,24 @@ export default { senderName: 'Nom de l\'expéditeur', senderMail: 'Adresse email de l\'expéditeur', replyTo: 'Adresse email de réponse', - createdAt: 'Data de création', + createdAt: 'Date de création', actions: 'Actions', - edit: 'Editer', + edit: 'Modifier', delete: 'Supprimer', contentSendType: 'Le type du contenu', emptyState: 'Aucun profil disponible', - warningNoFTP: - 'Vous ne pouvez pas ajouter de profil sans avoir configuré le serveur FTP', - deleteWarningMessage: - 'Vous êtes sur le point de supprimer le profil "{name}".Cette action ne peut pas être annulée.', + warningNoFTP: 'Vous ne pouvez pas ajouter de profil sans avoir configuré un serveur FTP au préalable.', + deleteWarningMessage: 'Vous êtes sur le point de supprimer le profil : {name}. Cette action est irréversible.', }, folders: { name: 'Nom du dossier', - nameUpdated: 'Dossier renommé', - renameTitle: - 'Renommer le dossier {name} ', + nameUpdated: 'Le dossier a bien été renommé', + renameTitle: 'Renommer le dossier : {name}', rename: 'Renommer', - created: 'Dossier créé', - conflict: 'Le dossier existe déjà', + created: 'Le dossier a bien été créé', + conflict: 'Un dossier avec le même nom existe déjà', moveFolderConfirmationMessage: 'Veuillez choisir la destination', - moveFolderSuccessful: 'Dossier déplacé', + moveFolderSuccessful: 'Le dossier a bien été déplacé', }, tableHeaders: { groups: { diff --git a/packages/ui/routes/groups/_groupId/emails-groups/_emailsGroupId/index.vue b/packages/ui/routes/groups/_groupId/emails-groups/_emailsGroupId/index.vue new file mode 100644 index 00000000..3019fd02 --- /dev/null +++ b/packages/ui/routes/groups/_groupId/emails-groups/_emailsGroupId/index.vue @@ -0,0 +1,93 @@ + + + diff --git a/packages/ui/routes/groups/_groupId/index.vue b/packages/ui/routes/groups/_groupId/index.vue index 9ced3450..da6d0c7d 100644 --- a/packages/ui/routes/groups/_groupId/index.vue +++ b/packages/ui/routes/groups/_groupId/index.vue @@ -11,9 +11,9 @@ import BsGroupTemplatesTab from '~/components/group/templates-tab.vue'; import BsGroupMailingsTab from '~/components/group/mailings-tab.vue'; import BsGroupUsersTab from '~/components/group/users-tab.vue'; import BsGroupWorkspacesTab from '~/components/group/workspaces-tab.vue'; +import BsEmailsGroupsTab from '~/components/group/emails-groups-tab.vue'; import BsGroupProfilesTab from '~/components/group/profile-tab.vue'; import { IS_ADMIN, IS_GROUP_ADMIN, USER } from '~/store/user'; -import { PAGE_NAMES } from '~/helpers/constants/page-names.js'; export default { name: 'BsPageGroup', @@ -25,21 +25,12 @@ export default { BsGroupMailingsTab, BsGroupWorkspacesTab, BsGroupProfilesTab, + BsEmailsGroupsTab, }, mixins: [mixinPageTitle], meta: { acl: [acls.ACL_ADMIN, acls.ACL_GROUP_ADMIN], }, - beforeRouteEnter(to, from, next) { - next((page) => { - if ( - from.name === PAGE_NAMES.WORKSPACE_CREATE || - from.name === PAGE_NAMES.WORKSPACE_UPDATE - ) { - page.fromCreateOrUpdate = true; - } - }); - }, async asyncData(nuxtContext) { const { $axios, params } = nuxtContext; try { @@ -53,7 +44,6 @@ export default { return { group: {}, loading: false, - fromCreateOrUpdate: false, }; }, head() { @@ -66,9 +56,9 @@ export default { isGroupAdmin: IS_GROUP_ADMIN, }), tab() { - return this.fromCreateOrUpdate - ? 'group-workspaces' - : 'group-informations'; + return this.$route.query.redirectTab + ? this.$route.query.redirectTab + : 'informations'; }, title() { return `${this.$tc('global.group', 1)} – ${this.group.name}`; @@ -111,7 +101,7 @@ export default { - + {{ $t('groups.tabs.informations') }} @@ -131,6 +121,9 @@ export default { {{ $tc('global.profile', 2) }} + + {{ $tc('global.emailsGroups', 2) }} + + + + diff --git a/packages/ui/routes/groups/_groupId/new-emails-group.vue b/packages/ui/routes/groups/_groupId/new-emails-group.vue new file mode 100644 index 00000000..cd9912f2 --- /dev/null +++ b/packages/ui/routes/groups/_groupId/new-emails-group.vue @@ -0,0 +1,81 @@ + + + diff --git a/packages/ui/routes/groups/_groupId/profiles/_profileId.vue b/packages/ui/routes/groups/_groupId/profiles/_profileId.vue index 8ec259f7..8af3afb8 100644 --- a/packages/ui/routes/groups/_groupId/profiles/_profileId.vue +++ b/packages/ui/routes/groups/_groupId/profiles/_profileId.vue @@ -74,8 +74,6 @@ export default { }; } - console.log({ profileForAdmin: profileData }); - return { profile: profileData, }; diff --git a/packages/ui/routes/mailings/__partials/mailings-download-modal.vue b/packages/ui/routes/mailings/__partials/mailings-download-modal.vue new file mode 100644 index 00000000..e1bd339b --- /dev/null +++ b/packages/ui/routes/mailings/__partials/mailings-download-modal.vue @@ -0,0 +1,98 @@ + + + diff --git a/packages/ui/routes/mailings/__partials/mailings-selection-actions.vue b/packages/ui/routes/mailings/__partials/mailings-selection-actions.vue index f41e638a..a03deab5 100644 --- a/packages/ui/routes/mailings/__partials/mailings-selection-actions.vue +++ b/packages/ui/routes/mailings/__partials/mailings-selection-actions.vue @@ -1,22 +1,34 @@ @@ -111,6 +208,40 @@ export default { }}
+ + + {{ + $tc('mailings.downloadFtpCount', selectionLength, { + count: selectionLength, + }) + }} + + + + {{ + $tc('mailings.downloadCount', selectionLength, { + count: selectionLength, + }) + }} + +
diff --git a/packages/ui/routes/mailings/__partials/mailings-table.vue b/packages/ui/routes/mailings/__partials/mailings-table.vue index e6f68e51..6c780061 100644 --- a/packages/ui/routes/mailings/__partials/mailings-table.vue +++ b/packages/ui/routes/mailings/__partials/mailings-table.vue @@ -36,6 +36,8 @@ const TABLE_ACTIONS = [ ACTIONS.COPY_MAIL, ACTIONS.MOVE_MAIL, ACTIONS.PREVIEW, + ACTIONS.DOWNLOAD, + ACTIONS.DOWNLOAD_FTP, 'actionMoveMail', ]; @@ -57,6 +59,7 @@ export default { mailings: { type: Array, default: () => [] }, mailingsSelection: { type: Array, default: () => [] }, tags: { type: Array, default: () => [] }, + hasFtpAccess: { type: Boolean, default: false }, }, data() { return { @@ -339,6 +342,12 @@ export default { tags, }); }, + async handleDownloadMail({ mailing, isWithFtp }) { + this.$emit('on-single-mail-download', { + mailing, + isWithFtp, + }); + }, }, }; @@ -429,6 +438,26 @@ export default { > {{ $t(actionsDetails[actions.DELETE].text) }} + + {{ $t(actionsDetails[actions.DOWNLOAD].text) }} + + + {{ $t(actionsDetails[actions.DOWNLOAD_FTP].text) }} + diff --git a/packages/ui/routes/mailings/index.vue b/packages/ui/routes/mailings/index.vue index 2d392db9..08b225b7 100644 --- a/packages/ui/routes/mailings/index.vue +++ b/packages/ui/routes/mailings/index.vue @@ -9,6 +9,7 @@ import { getWorkspace, getWorkspaceAccess, mailings, + groupsItem, } from '~/helpers/api-routes.js'; import BsMailingsModalNew from '~/routes/mailings/__partials/mailings-new-modal.vue'; import { ACL_USER } from '~/helpers/pages-acls.js'; @@ -36,8 +37,14 @@ export default { redirect('/groups'); } }, - async asyncData({ $axios, query }) { + async asyncData({ $axios, query, store }) { try { + let group = null; + if (store?.state?.user?.info?.group?.id) { + group = await $axios.$get( + groupsItem({ groupId: store.state.user.info.group.id }) + ); + } if (!!query?.wid || !!query?.fid) { let folder; let workspace; @@ -79,6 +86,7 @@ export default { folder, workspace, hasAccess, + hasFtpAccess: !!group?.downloadMailingWithFtpImages, }; } } catch (error) { @@ -95,6 +103,7 @@ export default { tags: [], filterValues: null, hasAccess: false, + hasFtpAccess: false, }), computed: { filteredMailings() { @@ -206,6 +215,12 @@ export default { this.loading = false; } }, + async handleDownloadSingleMail({ mailing, isWithFtp }) { + this.$refs.mailingSelectionActions.handleInitSingleDownload({ + mailing, + isWithFtp, + }); + }, async refreshLeftMenuData() { await this.$refs.workspaceTree.fetchData(); }, @@ -250,8 +265,10 @@ export default { diff --git a/public/lang/badsender-en.js b/public/lang/badsender-en.js index 7825fcea..23cd1644 100644 --- a/public/lang/badsender-en.js +++ b/public/lang/badsender-en.js @@ -5,15 +5,18 @@ module.exports = { 'edit-title-double-click': 'Double click to edit', 'edit-title-cancel': 'Cancel edition', 'edit-title-save': 'Save the new name', - 'edit-title-ajax-pending': 'changing name…', + 'edit-title-ajax-pending': 'Changing name…', 'edit-title-ajax-success': 'Name changed', - 'edit-title-ajax-fail': 'Unable to save the new name', + 'edit-title-ajax-fail': 'Unable to save the new name :(', + // empty title fallback 'title-empty': 'no name', - // - 'save-message-success': 'The mailing has been saved', - 'save-message-error': 'Error in saving', - // + + // save + 'save-message-success': 'The email has been saved', + 'save-message-error': 'Error in saving :(', + + // gallery 'gallery-title': 'Galleries:', 'gallery-mailing': 'EMAIL ONLY', 'gallery-mailing-loading': 'Loading email gallery…', @@ -23,22 +26,27 @@ module.exports = { 'gallery-template-empty': 'Email gallery is empty', 'gallery-remove-image-success': 'This image has been removed from the gallery', - 'gallery-remove-image-fail': 'An error has occured while removing the image', + 'gallery-remove-image-fail': + 'An error has occured while removing the image :(', + // bgimage widget - 'widget-bgimage-button': 'pick an image', - 'widget-bgimage-reset': 'reset image', + 'widget-bgimage-button': 'Pick an image', + 'widget-bgimage-reset': 'Reset image', + // prevent i18n console.warn 'Fake image editor': '', '

Fake image editor

': '', + // download button 'dl-btn-regular': 'Standard download', 'dl-btn-cdn': 'zip with CDN', + // Crop interface 'rotate-left': 'Rotate left', 'rotate-right': 'Rotate right', 'vertical-mirror': 'Vertical mirror', 'horizontal-mirror': 'Horizontal mirror', - 'error-server': 'An error has occured while calling server api', + 'error-server': 'An error has occured while calling server API :(', cancel: 'CANCEL', upload: 'UPLOAD', @@ -47,31 +55,45 @@ module.exports = { 'sender-mail': 'Sender email address', replyto: 'Reply email address', mailSubject: 'Subject', - templateSubject: 'Subject', + templateSubject: 'Template subject', name: 'Name', - mailName: 'Mail name', + mailName: 'Email name', + templateName: 'Template name', 'export-to': 'Export to', - exporting: 'Exporting ...', + exporting: 'Exporting…', loading: 'Loading', submit: 'Submit', - export: 'Export', - close: 'Close', + close: 'CANCEL', + export: 'EXPORT', + 'warning-esp-message': 'Any update will replace the email in your ESP', + // profile form validation - 'warning-esp-message': - 'Any update will override the mail campaign in the email service provider.', - 'mail-name-required': 'Mail name is required', + 'mail-name-required': 'Email name is required', 'template-name-required': 'Template name is required', - 'mail-subject-required': 'Subject is required', - 'template-subject-required': 'Subject is required', - 'mail-success-esp-send': 'Mail exported successfully', + 'mail-subject-required': 'Email subject is required', + 'template-subject-required': 'Template subject is required', + 'mail-success-esp-send': 'Email exported successfully', 'template-success-esp-send': 'Template exported successfully', 'error-server-400': - 'Esp parameters invalids, check if sender mail matches API key.', - 'error-server-402': 'Export fail, provider require payment', - 'error-server-409': 'Mail name already used', - 'error-server-500': 'An error has occured while calling server api', + 'ESP parameters invalids. Check if sender email matches API key', + 'error-server-402': 'Export fail, provider require payment.', + 'error-server-409': 'Email name already used', + 'error-server-500': 'An error has occured while calling server API :(', 'supported-language': 'Supported language', 'target-table': 'Target table', 'encoding-type': 'Encoding type', entity: 'Entity', + + // test list + 'title-send-test-mails': 'Send a test email', + 'send-test-success': 'Email sent successfully', + 'send-test-error': 'An error has occured while sending the emai :(l', + 'placeholder-input-emails-test': + 'Example: firstemail@test.com;secondemail@test.com', + 'emails-test': 'Enter one or more emails', + 'emails-invalid': + 'The email addresses entered are invalid. Please separate email addresses with ";"', + 'placeholder-emails-groups': 'Select a list', + 'sending-test-mails': 'Sending test mail…', + 'send-test-mails': 'Send', }; diff --git a/public/lang/badsender-fr.js b/public/lang/badsender-fr.js index 39fcf20e..30810d2e 100644 --- a/public/lang/badsender-fr.js +++ b/public/lang/badsender-fr.js @@ -2,79 +2,100 @@ module.exports = { // edit title - 'edit-title-double-click': 'Double-cliquer pour éditer', - 'edit-title-cancel': 'Annuler l\'édition', + 'edit-title-double-click': 'Double-cliquer pour modifier', + 'edit-title-cancel': 'Annuler la modification', 'edit-title-save': 'Enregistrer le nouveau nom', 'edit-title-ajax-pending': 'Changement du nom…', 'edit-title-ajax-success': 'Mise à jour du nom effectuée', - 'edit-title-ajax-fail': 'Impossible d\'enregistrer le nouveau nom', + 'edit-title-ajax-fail': "Impossible d'enregistrer le nouveau nom :(", + // empty title fallback 'title-empty': 'sans titre', - // - 'save-message-success': 'L\'email a été sauvé', - 'save-message-error': 'Une erreur est survenue lors de l\'enregistrement', - // + + // save + 'save-message-success': "L'email a été sauvegardé", + 'save-message-error': "Une erreur est survenue lors de l'enregistrement :(", + + // gallery 'gallery-title': 'Galeries :', - 'gallery-mailing': 'SPÉCIFIQUE À L\'EMAIL', - 'gallery-mailing-loading': 'Chargement de la galerie de l\'email…', - 'gallery-mailing-empty': 'La galerie de l\'email est vide', + 'gallery-mailing': "SPÉCIFIQUE À L'EMAIL", + 'gallery-mailing-loading': "Chargement de la galerie de l'email…", + 'gallery-mailing-empty': "La galerie de l'email est vide", 'gallery-template': 'COMMUN AU TEMPLATE', 'gallery-template-loading': 'Chargement de la galerie du template…', 'gallery-template-empty': 'La galerie du template est vide', - 'gallery-remove-image-success': 'L\'image a bien été enlevée de la galerie', + 'gallery-remove-image-success': "L'image a bien été supprimée de la galerie", 'gallery-remove-image-fail': - 'Une erreur est survenue lors de la suppression de l\'image', + "Une erreur est survenue lors de la suppression de l'image :(", + // bgimage widget - 'widget-bgimage-button': 'choisissez une image', - 'widget-bgimage-reset': 'enlever l\'image', + 'widget-bgimage-button': 'Choisissez une image', + 'widget-bgimage-reset': "Enlever l'image", + // prevent i18n console.warn 'Fake image editor': '', '

Fake image editor

': '', + // download button 'dl-btn-regular': 'Téléchargement local', 'dl-btn-cdn': 'images ICOU', + // Crop interface - 'rotate-left': 'Incliner à gauche', - 'rotate-right': 'Incliner à droite', + 'rotate-left': 'Tourner vers la gauche', + 'rotate-right': 'Tourner vers la droite', 'vertical-mirror': 'Miroir vertical', 'horizontal-mirror': 'Miroir horizontal', 'error-server': - 'Une erreur s\'est produite lors de l\'appel de l\'API du serveur', + "Une erreur s'est produite lors de l'appel de l'API du serveur", cancel: 'ANNULER', - upload: 'UPLOADER', + upload: 'ENVOYER', // Profile form esp - 'sender-name': 'Nom de l\'expéditeur', - 'sender-mail': 'Adresse email de l\'expéditeur', + 'sender-name': "Nom de l'expéditeur", + 'sender-mail': "Adresse email de l'expéditeur", replyto: 'Adresse email de réponse', - mailSubject: 'Objet du mail', + mailSubject: "Objet de l'email", templateSubject: 'Objet du template', name: 'Nom', - mailName: 'Nom du mail', + mailName: "Nom de l'email", templateName: 'Nom du template', 'export-to': 'Exporter vers', - exporting: 'Exportation en cours ...', - loading: 'Loading', + exporting: 'Exportation…', + loading: 'Chargement', submit: 'Enregistrer', - close: 'Fermer', - export: 'Exporter', + close: 'ANNULER', + export: 'EXPORTER', 'warning-esp-message': - 'Toute mise à jour remplacera le mail dans le fournisseur de services de messagerie', + "Toute mise à jour remplacera l'email dans votre routeur", + // profile form validation - 'mail-name-required': 'Le nom du mail est requis', - 'template-name-required': 'Le nom du template est requis', - 'mail-subject-required': 'L\'Objet du mail est requis', - 'template-subject-required': 'L\'Objet du template est requis', - 'mail-success-esp-send': 'Mail exporté avec succès', + 'mail-name-required': "Veuillez saisir un nom pour l'email", + 'template-name-required': 'Veuillez saisir un nom pour le template', + 'mail-subject-required': "Veuillez saisir l'objet de l'email", + 'template-subject-required': "euillez saisir l'objet du template", + 'mail-success-esp-send': 'Email exporté avec succès', 'template-success-esp-send': 'Template exporté avec succès', 'error-server-400': - 'Paramètres Esp invalides, vérifiez si le courrier de l\'expéditeur correspond à la clé API', - 'error-server-402': 'Échec de l\'exportation, l\'ESP exige un paiement', - 'error-server-409': 'Nom de mail déjà utilisé', + "Paramètres ESP invalides. Vérifiez si l'adresse email de l'expéditeur correspond à la clé API", + 'error-server-402': "Échec de l'exportation. L'ESP exige un paiement.", + 'error-server-409': "Nom d'email déjà utilisé", 'error-server-500': - 'Une erreur s\'est produite lors de l\'appel de l\'API du serveur', + "Une erreur s'est produite lors de l'appel de l'API du serveur :(", 'supported-language': 'Langue', 'target-table': 'Table cible', - 'encoding-type': 'Type d\'encodage', + 'encoding-type': "Type d'encodage", entity: 'Entité', + + // test list + 'title-send-test-mails': 'Envoyer un email de test', + 'send-test-success': 'Email envoyé avec succès', + 'send-test-error': "Erreur lors de l'envoi de l'email :(", + 'placeholder-input-emails-test': + 'Exemple : premieremail@test.com;secondemail@test.com', + 'emails-test': 'Saisir un ou plusieurs emails', + 'emails-invalid': + 'Les adresses emails saisis sont invalides. Veuillez séparer les adresses emails par ";"', + 'placeholder-emails-groups': 'Sélectionnez une liste', + 'sending-test-mails': "Envoi de l'email de test…", + 'send-test-mails': 'Envoyer', }; diff --git a/yarn.lock b/yarn.lock index 09e1c33d..af9e9769 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19268,6 +19268,11 @@ vue-router@^3.4.5, vue-router@^3.4.9: resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-3.5.2.tgz#5f55e3f251970e36c3e8d88a7cd2d67a350ade5c" integrity sha512-807gn82hTnjCYGrnF3eNmIw/dk7/GE4B5h69BlyCK9KHASwSloD1Sjcn06zg9fVG4fYH2DrsNBZkpLtb25WtaQ== +vue-select@3.13.0: + version "3.13.0" + resolved "https://registry.yarnpkg.com/vue-select/-/vue-select-3.13.0.tgz#78d519984139156adcbf56af260fb6c2a69093a6" + integrity sha512-+PcWtfA1i3WVtkVwBPQknnOZL6QWSD2XiAVbSn0xAQvjOrmGAg7z+o9HkezXhtGdEstloJOdM8SujrUVyphKqg== + vue-server-renderer@^2.6.10, vue-server-renderer@^2.6.12: version "2.6.14" resolved "https://registry.yarnpkg.com/vue-server-renderer/-/vue-server-renderer-2.6.14.tgz#c8bffff152df6b47b858818ef8d524d2fc351654"