Filter Email Attachments When Creating An Email

Collapse
X
 
  • Time
  • Show
Clear All
new posts
  • Alex@alexdraper.ca
    Junior Member
    • May 2025
    • 15

    #1

    Filter Email Attachments When Creating An Email

    I'm trying to send an email using the email button in the activity pane and I would like to attach documents related to that entity.

    For example, I'm in an Opportunity record, I click the email icon in activities, select an email template then click attach, insert document. That select pane shows all documents in the system, but I want it to only show documents related to that Opportunity.

    I know the relationship is many to many between documents and opportunities so that might be making things more difficult.

    Any help to accomplish this would be much appreciated.
  • jamie
    Senior Member
    • Aug 2025
    • 215

    #2
    that sounds like a really cool feature

    Comment

    • Alex@alexdraper.ca
      Junior Member
      • May 2025
      • 15

      #3
      Ya it would make life really easy, not sure why this isn't the default behavior. Hopefully someone here can provide some insight, or point me in the right direction.

      Comment

      • yuri
        EspoCRM product developer
        • Mar 2014
        • 9603

        #4
        Making this the default behavior will be undesired for many who usually attach documents unrelated to the record. I believe the majority won't attach documents the the opportunity at all. Probably would make more harm for users than benefit. To the question why it's not the default.

        Comment


        • yuri
          yuri commented
          Editing a comment
          As the system is open source, I encourage to look though the code to find a solution. We are quite loaded with work these months.
      • Alex@alexdraper.ca
        Junior Member
        • May 2025
        • 15

        #5
        Ok thank you. any guidance you could provide would be awesome, I've tried a few solutions but haven't been able to make it work. If I find a solution I'll post here and may it could be integrated into a future release (as a built in filter or something)

        Comment

        • Alex@alexdraper.ca
          Junior Member
          • May 2025
          • 15

          #6
          So in case someone was interested in this, I've managed to add a new part to the email insert modal that says Insert Related Document

          put compose.js in client/custom/src/views/email/record/compose.js
          put select-related-documents.js in client/custom/src/views/modals/select-related-documents.js

          Add this to Email.json in clientDefs
          {
          "recordViews": {
          "compose": "custom:views/email/record/compose"
          }
          }

          I thought about making this an extension, but this forum has helped me quite a bit and I hope this helps someone else, and maybe gets incorporated into the next release as I found it extremely useful for me.


          compose.js
          Code:
          define('custom:views/email/record/compose', 'views/email/record/compose', function (Dep) {
              return Dep.extend({
                  setup: function () {
                      Dep.prototype.setup.call(this);
                      this._createdAttachmentIds = new Set();   // only attachments created by our related-doc flow
                      this._prevAttachmentsIds = (this.model.get('attachmentsIds') || []).slice();
                      this.bindAttachmentCleanup_();
                      this.bindReinjectOnAttachmentChange_();
                  },
                  afterRender: function () {
                      Dep.prototype.afterRender.call(this);
                      this.ensureInsertRelatedAction_();
                  },
                  remove: function () {
                      this.cleanupCreatedAttachmentsOnClose_();
                      this.unbindAttachmentCleanup_();
                      this.unbindReinjectOnAttachmentChange_();
                      return Dep.prototype.remove.call(this);
                  },
                  hasParentContext_: function () {
                      const parentType = this.model && this.model.get('parentType');
                      const parentId   = this.model && this.model.get('parentId');
                      return !!(parentType && parentId);
                  },
                  // ----------------------------------------------------
                  // UI: add "Insert Related Document" beside default insert
                  // ----------------------------------------------------
                  ensureInsertRelatedAction_: function () {
                      if (!this.hasParentContext_()) return;
                      const $default = this.$el.find('a[data-action="insertFromSource"][data-name="Document"]').first();
                      if (!$default.length) return;
                      // If already present, do nothing
                      if (this.$el.find('a[data-action="insertRelatedDocument"]').length) return;
                      const $btn = $('<a>')
                          .addClass('action')
                          .attr('href', 'javascript:')
                          .attr('data-action', 'insertRelatedDocument')
                          .text('Insert Related Document');
                      $default.after($btn);
                      // Use framework action handler (via data-action) by ensuring click bubbles.
                      // But since this link is dynamically injected, we still bind a lightweight handler.
                      $btn.on('click', (e) => {
                          e.preventDefault();
                          e.stopPropagation();
                          this.openRelatedDocumentsPicker_();
                      });
                  },
                  bindReinjectOnAttachmentChange_: function () {
                      this._reinjectTimer = null;
                      this._reinjectFn = () => {
                          // Debounce to allow Espo to finish any re-render that removed our injected link
                          clearTimeout(this._reinjectTimer);
                          this._reinjectTimer = setTimeout(() => {
                              try {
                                  this.ensureInsertRelatedAction_();
                              } catch (e) {}
                          }, 0);
                      };
                      this.listenTo(this.model, 'change:attachmentsIds', this._reinjectFn);
                  },
                  unbindReinjectOnAttachmentChange_: function () {
                      try {
                          if (this._reinjectFn) {
                              this.stopListening(this.model, 'change:attachmentsIds', this._reinjectFn);
                          }
                      } catch (e) {}
                      this._reinjectFn = null;
                      if (this._reinjectTimer) {
                          clearTimeout(this._reinjectTimer);
                      }
                      this._reinjectTimer = null;
                  },
                  // ----------------------------------------------------
                  // Modal: related-documents picker (modes)
                  // ----------------------------------------------------
                  openRelatedDocumentsPicker_: function () {
                      const parentType = this.model.get('parentType');
                      const parentId   = this.model.get('parentId');
                      if (!parentType || !parentId) return;
                      const relatedPath = `${parentType}/${parentId}/documents`;
                      const expandedConfig = [
                          { includeParentItself: true, relatedEntityType: parentType, documentLink: 'opportunities' },
                          { parentLink: 'account',  relatedEntityType: 'Account',  documentLink: 'accounts' },
                          { parentLink: 'contacts', relatedEntityType: 'Contact',  documentLink: 'contacts' }
                      ];
                      this.createView(
                          'selectDocsModes',
                          'custom:views/modals/select-related-documents',
                          {
                              entityType: 'Document',
                              scope: 'Document',
                              multiple: true,
                              createButton: false,
                              relatedPath: relatedPath,
                              parentType: parentType,
                              parentId: parentId,
                              expandedConfig: expandedConfig
                          },
                          (view) => {
                              view.render();
                              this.listenToOnce(view, 'select', (models) => {
                                  const list = models || [];
                                  this.convertDocumentsToAttachmentsAndLink_(list)
                                      .then(() => {
                                          view.close();
                                          // ensure our action survives any re-render
                                          this.ensureInsertRelatedAction_();
                                      })
                                      .catch(() => {
                                          view.close();
                                          this.ensureInsertRelatedAction_();
                                      });
                              });
                          }
                      );
                  },
                  // ----------------------------------------------------
                  // Document selection => create Attachment entities
                  // ----------------------------------------------------
                  convertDocumentsToAttachmentsAndLink_: function (docModels) {
                      const emailModel = this.model;
                      const docIds = (docModels || []).map(m => m.get('id')).filter(Boolean);
                      if (!docIds.length) return Promise.resolve(true);
                      const existingIds = (emailModel.get('attachmentsIds') || []).slice();
                      const existingSet = new Set(existingIds);
                      const existingNames = emailModel.get('attachmentsNames') || {};
                      const namesMap = _.isArray(existingNames) ? {} : _.clone(existingNames);
                      const existingTypes = emailModel.get('attachmentsTypes') || {};
                      const typesMap = _.isArray(existingTypes) ? {} : _.clone(existingTypes);
                      const jobs = docIds.map((docId) => {
                          // This is the key: server returns/creates Attachment records for the Document
                          return Espo.Ajax.postRequest('Document/action/getAttachmentList', {
                              id: docId,
                              field: 'attachments',
                              parentType: 'Email'
                          }).then((list) => list || []);
                      });
                      return Promise.all(jobs).then((resLists) => {
                          const attachmentList = [].concat.apply([], resLists);
                          attachmentList.forEach((item) => {
                              const attId = item && item.id;
                              if (!attId) return;
                              // Track only attachments that were NOT already on the email
                              if (!existingSet.has(attId)) {
                                  this._createdAttachmentIds.add(attId);
                                  existingSet.add(attId);
                                  existingIds.push(attId);
                              }
                              const nm = item.name || item.fileName || item.filename || ('Attachment ' + attId);
                              const tp = item.type || item.mimeType || item.fileType;
                              namesMap[attId] = nm;
                              if (tp) typesMap[attId] = tp;
                          });
                          emailModel.set({
                              attachmentsIds: existingIds,
                              attachmentsNames: namesMap,
                              attachmentsTypes: typesMap
                          });
                          emailModel.trigger('change:attachmentsIds', emailModel, existingIds);
                          emailModel.trigger('change:attachmentsNames', emailModel, namesMap);
                          emailModel.trigger('change:attachmentsTypes', emailModel, typesMap);
                          return true;
                      });
                  },
                  // ----------------------------------------------------
                  // Cleanup: when X removes an attachment, delete it (only if we created it)
                  // ----------------------------------------------------
                  bindAttachmentCleanup_: function () {
                      this._onAttachmentsChange = () => {
                          const prev = this._prevAttachmentsIds || [];
                          const cur  = (this.model.get('attachmentsIds') || []).slice();
                          const curSet = new Set(cur);
                          const removed = prev.filter(id => !curSet.has(id));
                          // Only delete ones we created via document insert
                          const toDelete = removed.filter(id => this._createdAttachmentIds && this._createdAttachmentIds.has(id));
                          if (toDelete.length) {
                              this.deleteAttachments_(toDelete);
                              toDelete.forEach(id => this._createdAttachmentIds.delete(id));
                          }
                          this._prevAttachmentsIds = cur;
                      };
                      this.listenTo(this.model, 'change:attachmentsIds', this._onAttachmentsChange);
                  },
                  unbindAttachmentCleanup_: function () {
                      try {
                          if (this._onAttachmentsChange) {
                              this.stopListening(this.model, 'change:attachmentsIds', this._onAttachmentsChange);
                          }
                      } catch (e) {}
                      this._onAttachmentsChange = null;
                  },
                  deleteAttachments_: function (ids) {
                      (ids || []).forEach((id) => {
                          const p = Espo.Ajax.deleteRequest
                              ? Espo.Ajax.deleteRequest(`Attachment/${id}`)
                              : Espo.Ajax.request('DELETE', `Attachment/${id}`);
                          Promise.resolve(p).catch(() => {});
                      });
                  },
                  // ----------------------------------------------------
                  // Close modal => delete created attachments if still Draft
                  // ----------------------------------------------------
                  cleanupCreatedAttachmentsOnClose_: function () {
                      try {
                          const status = this.model && this.model.get('status');
                          if (status && status !== 'Draft') return;
                          if (!this._createdAttachmentIds || !this._createdAttachmentIds.size) return;
                          const currentIds = new Set((this.model.get('attachmentsIds') || []).slice());
                          // Only delete ones we created AND that are still on the email
                          const toDelete = [];
                          this._createdAttachmentIds.forEach((id) => {
                              if (currentIds.has(id)) toDelete.push(id);
                          });
                          if (toDelete.length) {
                              this.deleteAttachments_(toDelete);
                          }
                      } catch (e) {}
                  }
              });
          });
          I'll post the other file in the next post (character limit)
          For the record, it was a joint effort between me and ChatGPT haha.

          I hope you find this useful

          Comment

          • Alex@alexdraper.ca
            Junior Member
            • May 2025
            • 15

            #7
            select-related-documents.js

            Code:
            define('custom:views/modals/select-related-documents', 'views/modals/select-records', function (Dep) {
                return Dep.extend({
                    defaultMode: 'oppOnly', // all | oppOnly | expanded
                    setup: function () {
                        Dep.prototype.setup.call(this);
                        this.relatedPath    = this.options.relatedPath || null;
                        this.parentType     = this.options.parentType || null;
                        this.parentId       = this.options.parentId || null;
                        this.expandedConfig = this.options.expandedConfig || [];
                        this._originalUrl  = this.collection && this.collection.url;
                        this._originalData = this.collection && this.collection.data ? _.clone(this.collection.data) : {};
                        if (this.relatedPath) {
                            this.applyMode_(this.defaultMode, { silent: true });
                        } else {
                            this.applyMode_('all', { silent: true });
                        }
                    },
                    afterRender: function () {
                        Dep.prototype.afterRender.call(this);
                        this.injectModeDropdown_();
                    },
                    injectModeDropdown_: function () {
                        const $header = this.$el.find('.modal-header');
                        if (!$header.length) return;
                        if ($header.find('.js-doc-mode').length) return;
                        const hasContext = !!(this.relatedPath && this.parentType && this.parentId);
                        const html = `
                            <div class="js-doc-mode" style="display:flex; gap:8px; align-items:center; margin-left:12px;">
                                <label style="margin:0; font-weight:600;">Show:</label>
                                <select class="form-control input-sm js-doc-mode-select" style="min-width: 360px;">
                                    <option value="all">All Documents</option>
                                    <option value="oppOnly">Related to this record</option>
                                    <option value="expanded">This record + linked entities</option>
                                </select>
                            </div>
                        `;
                        $header.append(html);
                        const $select = $header.find('.js-doc-mode-select');
                        $select.val(hasContext ? this.defaultMode : 'all');
                        $select.on('change', () => {
                            const mode = $select.val();
                            this.applyMode_(mode);
                        });
                    },
                    applyMode_: function (mode, opts) {
                        opts = opts || {};
                        if (!this.collection) return;
                        this.collection.offset = 0;
                        if (mode === 'all') {
                            this.collection.url = this._originalUrl;
                            this.collection.data = _.clone(this._originalData) || {};
                            delete this.collection.data.where;
                            delete this.collection.data.primaryFilter;
                            if (!opts.silent) this.collection.fetch({ reset: true });
                            return;
                        }
                        if (mode === 'oppOnly') {
                            if (!this.relatedPath) {
                                return this.applyMode_('all', opts);
                            }
                            this.collection.url = this.relatedPath;
                            this.collection.data = this.collection.data || {};
                            delete this.collection.data.where;
                            delete this.collection.data.primaryFilter;
                            if (!opts.silent) this.collection.fetch({ reset: true });
                            return;
                        }
                        if (mode === 'expanded') {
                            if (!this.parentType || !this.parentId || !Array.isArray(this.expandedConfig) || !this.expandedConfig.length) {
                                return this.applyMode_('oppOnly', opts);
                            }
                            this.collection.url = this._originalUrl;
                            this.collection.data = _.clone(this._originalData) || {};
                            delete this.collection.data.primaryFilter;
                            this.buildExpandedOrWhere_().then((where) => {
                                this.collection.data.where = where;
                                if (!opts.silent) this.collection.fetch({ reset: true });
                            }).catch(() => {
                                return this.applyMode_('oppOnly', opts);
                            });
                            return;
                        }
                        return this.applyMode_('all', opts);
                    },
                    buildExpandedOrWhere_: function () {
                        const parentType = this.parentType;
                        const parentId   = this.parentId;
                        const cfg        = this.expandedConfig;
                        const tasks = cfg.map((item) => this.resolveIdsForConfigItem_(item, parentType, parentId));
                        return Promise.all(tasks).then((resolved) => {
                            const or = [];
                            resolved.forEach((r, idx) => {
                                if (!r) return;
                                const item = cfg[idx];
                                const ids = r.ids || [];
                                ids.forEach((id) => {
                                    or.push({
                                        type: 'linkedWith',
                                        link: item.documentLink,
                                        id: id
                                    });
                                });
                            });
                            const seen = new Set();
                            const orDedup = or.filter(x => {
                                const k = `${x.link}:${x.id}`;
                                if (seen.has(k)) return false;
                                seen.add(k);
                                return true;
                            });
                            return [{ type: 'or', value: orDedup }];
                        });
                    },
                    resolveIdsForConfigItem_: function (item, parentType, parentId) {
                        if (!item || !item.documentLink) return Promise.resolve({ ids: [] });
                        if (item.includeParentItself) {
                            return Promise.resolve({ ids: [parentId] });
                        }
                        if (!item.parentLink) {
                            return Promise.resolve({ ids: [] });
                        }
                        const url = `${parentType}/${parentId}/${item.parentLink}`;
                        return Espo.Ajax.getRequest(url, { maxSize: 200, select: 'id' }).then((res) => {
                            const list = res && res.list ? res.list : (Array.isArray(res) ? res : []);
                            const ids = (list || []).map(r => r.id).filter(Boolean);
                            return { ids: ids };
                        }).catch(() => {
                            return { ids: [] };
                        });
                    }
                });
            });

            Comment

            • Alex@alexdraper.ca
              Junior Member
              • May 2025
              • 15

              #8
              Final note, this is probably not the most efficient or correct way of doing this, but it works. Hopefully this gets added to the next release and is incorporated in the correct fashion by someone who understands the system better than I do.

              Comment

              Working...