В этой серии статей мы шаг за шагом построим полноценный почтовый клиент на Ext JS. Начиная с базовой структуры и заканчивая REST API и deep linking. Автор оригинала — Stuart Ashworth (Sencha MVP), его книга «Building an Email Client from Scratch» состоит из 7 частей. Мы адаптируем материал для русскоязычного сообщества и добавим пояснения.
В первой части мы заложим фундамент: создадим Universal-приложение, опишем модели данных, настроим хранилища и построим первые UI-компоненты — таблицу сообщений и просмотрщик письма. К концу статьи у вас будет работающий каркас, на который мы будем наращивать функциональность в следующих частях.
🎯 Что мы строим: Ext Mail
Наш почтовый клиент будет называться Ext Mail. Он позволит:
-
просматривать список писем в Grid;
-
открывать письмо и читать его полностью;
-
фильтровать по меткам (Inbox, Sent, Drafts и т.д.);
-
создавать новые письма;
-
работать на десктопе и мобильных устройствах.
Вот как будет выглядеть главный экран в Classic Toolkit:


А так — в Modern Toolkit:


🛠️ Подготовка: Sencha Cmd и Ext JS SDK
Для начала вам понадобятся:
-
Ext JS SDK (можно скачать trial на официальном сайте Sencha).
-
Sencha Cmd — инструмент командной строки для сборки и генерации приложений.
Создаем новое Universal-приложение одной командой:
sencha generate app ExtMail ./ext-mail
Universal-приложение означает, что мы сможем собирать его как для Classic (десктоп), так и для Modern (мобильные) тулкитов, используя общий код.
📁 Структура проекта
После генерации вы получите следующую структуру:
-
app/— общий код для обоих тулкитов (модели, хранилища, контроллеры, вьюмодели). -
classic/src/— код, специфичный для Classic Toolkit. -
modern/src/— код для Modern Toolkit. -
data/— статические JSON-файлы (пока не настоящий бэкенд). -
resources/— стили, изображения.
Загрузчик классов сначала ищет класс в папке тулкита, а если не находит — в общей папке app/.
🗃️ Данные: модели, хранилища, JSON
На первом этапе мы не будем подключать реальный API. Вместо этого создадим три JSON-файла в папке data/:
-
contacts.json— контакты (имя, email, телефон). -
labels.json— метки в виде дерева (Inbox, Starred, Sent, Drafts и т.д.). -
messages.json— список писем.
Модель Message (app/model/Message.js)
Описываем поля письма: отправитель, тема, дата, текст, метки, флаги (прочитано, черновик, отправлено).
Ext.define('ExtMail.model.Message', { extend: 'Ext.data.Model', fields: [ { name: 'firstName' }, { name: 'lastName' }, { name: 'fullName', calculate: function(data) { return Ext.String.trim((data.firstName || '') + ' ' + (data.lastName || '')); }}, { name: 'email' }, { name: 'date', type: 'date', dateFormat: 'c' }, { name: 'subject' }, { name: 'message' }, { name: 'labels', type: 'auto', defaultValue: [] }, { name: 'unread', type: 'boolean' }, { name: 'draft', type: 'boolean' }, { name: 'outgoing', type: 'boolean' }, { name: 'sent', type: 'boolean' } ], // Генерация UUID для новых писем requires: ['Ext.data.identifier.Uuid'], identifier: 'uuid', // Вспомогательные методы для работы с метками hasLabel: function(labelId) { var labels = this.get('labels') || []; return labels.indexOf(labelId) >= 0; }, addLabel: function(labelId) { var labels = this.get('labels') || []; labels.push(labelId); this.set('labels', Ext.clone(labels)); }, removeLabel: function(labelId) { var labels = this.get('labels') || []; labels = Ext.Array.remove(labels, labelId); this.set('labels', Ext.clone(labels)); } });
Модели Contact и Label создаются аналогично. Label расширяет Ext.data.TreeModel, так как метки образуют дерево.
Хранилища (app/store/)
Создаем три хранилища:
-
Messages— для писем, с сортировкой по дате (DESC) и AJAX-прокси наmessages.json. -
Contacts— для контактов. -
Labels— на базеExt.data.TreeStoreдля древовидной структуры меток.
Пример Messages:
Ext.define('ExtMail.store.Messages', { extend: 'Ext.data.Store', alias: 'store.Messages', model: 'ExtMail.model.Message', autoLoad: true, sorters: [{ property: 'date', direction: 'DESC' }], proxy: { type: 'ajax', url: 'data/messages.json', reader: { type: 'json', rootProperty: 'rows' } } });
🧠 Общие ViewController и ViewModel
Чтобы избежать дублирования кода между Classic и Modern, создаем базовые классы:
-
MainControllerBase— общий контроллер. -
MainModel— общая вьюмодель (состояние приложения).
В тулкит-специфичных папках создаем наследников:
-
classic/src/view/main/MainController.js— расширяетMainControllerBase. -
modern/src/view/main/MainController.js— аналогично.
В MainModel определяем данные и хранилища:
Ext.define('ExtMail.view.main.MainModel', { extend: 'Ext.app.ViewModel', alias: 'viewmodel.main', data: { selectedMessage: null }, formulas: { messageCardIndex: function(get) { return get('selectedMessage') ? 1 : 0; } }, stores: { messages: { type: 'Messages' } } });
Здесь selectedMessage — текущее выбранное письмо, а формула messageCardIndex вычисляет, какую карточку показывать (0 — Grid, 1 — Reader).
🖥️ Создаем MessageGrid
Класс ExtMail.view.messages.MessageGrid расширяет Ext.grid.Panel. Определяем колонки: полное имя, тема, дата. Привязываем store к вьюмодели.
Ext.define('ExtMail.view.messages.MessageGrid', { extend: 'Ext.grid.Panel', alias: 'widget.messages-MessageGrid', cls: 'message-grid', columns: [ { dataIndex: 'fullName', minWidth: 250, header: false, tdCls: 'full-name' }, { dataIndex: 'subject', flex: 1, header: false }, { xtype: 'datecolumn', dataIndex: 'date', header: 'Received', width: 100, align: 'end', format: 'j M \'y' } ], viewConfig: { getRowClass: function(record) { return record.get('unread') ? 'unread' : ''; } } });
Стилизуем непрочитанные письма жирным шрифтом через SCSS-файл MessageGrid.scss.
📖 Создаем MessageReader
Компонент для отображения полного текста письма. Используем Ext.XTemplate для вывода данных.
Ext.define('ExtMail.view.reader.MessageReader', { extend: 'Ext.panel.Panel', alias: 'widget.reader-MessageReader', cls: 'message-reader', tpl: [ '<div class="subject">{subject}</div>', '<div class="info">', '<div class="sender">', '<span class="name">{fullName}</span>', '<span class="email">({email})</span>', '</div>', '<div class="date">{date:date("D, j M Y, H:i")}</div>', '</div>', '<div class="message">{message}</div>' ] });
🔄 Навигация между Grid и Reader
В главном компоненте Main используем Card layout для переключения между списком и просмотром. Оборачиваем оба компонента в панель с layout: 'card', привязываем activeItem к формуле messageCardIndex.
{ xtype: 'panel', region: 'center', layout: 'card', reference: 'messagesWrapper', bind: { activeItem: '{messageCardIndex}' }, items: [ { xtype: 'messages-MessageGrid', bind: { store: '{messages}' }, listeners: { itemclick: 'onMessageClick' } }, { xtype: 'reader-MessageReader', bind: { data: '{selectedMessage}' } } ] }
В контроллере обрабатываем клик по строке:
onMessageClick: function(grid, record) { this.getViewModel().set('selectedMessage', record); }
🧰 Динамический Toolbar
Создаем панель инструментов MessagesToolbar, которая меняет содержимое в зависимости от контекста. На Grid показываем кнопку «Обновить», на Reader — «Назад».
Ext.define('ExtMail.view.messages.MessagesToolbar', { extend: 'Ext.toolbar.Toolbar', alias: 'widget.messages-MessagesToolbar', defaultListenerScope: true, items: [ { tooltip: 'Refresh', iconCls: 'x-fa fa-redo', handler: 'onRefreshClick', bind: { hidden: '{selectedMessage}' } }, { tooltip: 'Back', iconCls: 'x-fa fa-arrow-left', handler: 'onBackClick', bind: { hidden: '{!selectedMessage}' } } ], onRefreshClick: function() { this.fireEvent('refresh'); }, onBackClick: function() { this.fireEvent('back'); } });
В Main добавляем тулбар как dockedItem и подписываемся на его события:
dockedItems: [{ xtype: 'messages-MessagesToolbar', dock: 'top', listeners: { refresh: 'onRefreshMessages', back: 'onBackToMessagesGrid' } }]
Методы в контроллере:
onRefreshMessages: function() { this.getViewModel().getStore('messages').reload(); }, onBackToMessagesGrid: function() { this.getViewModel().set('selectedMessage', null); }
💎 Заключение
В первой части мы создали фундамент почтового клиента Ext Mail:
-
Настроили Universal-приложение и структуру папок.
-
Описали модели
Message,Contact,Label. -
Создали хранилища
Messages,Contacts,Labels. -
Построили общие ViewController и ViewModel.
-
Реализовали Grid для списка писем и Reader для просмотра.
-
Настроили навигацию через Card layout и динамический Toolbar.
В следующей части мы добавим метки, дерево и динамические действия: фильтрацию по меткам, отображение количества непрочитанных сообщений и расширенный тулбар. Следите за обновлениями!