From df977c089eb0fc9148a0e7c56d5ed4c2750c5ad2 Mon Sep 17 00:00:00 2001
From: defnax <9952056+defnax@users.noreply.github.com>
Date: Sat, 25 Jul 2026 23:40:21 +0200
Subject: [PATCH] Added attach file, image & emoji support to mail composer
---
webui-src/app/mail/mail_compose.js | 310 ++++++++++++++++++++++++++---
1 file changed, 286 insertions(+), 24 deletions(-)
diff --git a/webui-src/app/mail/mail_compose.js b/webui-src/app/mail/mail_compose.js
index 87fdcee..230d376 100644
--- a/webui-src/app/mail/mail_compose.js
+++ b/webui-src/app/mail/mail_compose.js
@@ -2,14 +2,28 @@ const m = require('mithril');
const rs = require('rswebui');
const widget = require('widgets');
const peopleUtil = require('people/people_util');
+const chatEmoji = require('chat/chat_emoji');
const UserAvatarsCache = {};
const MAX_RECIPIENTS = 20;
+function formatFileSize(bytes) {
+ if (!bytes) return '0 B';
+ const k = 1024;
+ const sizes = ['B', 'KB', 'MB', 'GB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
+}
+
const Layout = () => {
let showCc = false;
let showBcc = false;
let ownAvatars = {};
+ let attachments = [];
+ let showEmojiPicker = false;
+ let emojiSearch = '';
+ let emojiCategory = 'Smileys';
+
const Data = {
allUsers: [],
ownId: [],
@@ -34,6 +48,51 @@ const Layout = () => {
},
},
};
+
+ function insertContentIntoMailBody(content) {
+ const mailBody = document.querySelector('#composerMailBody');
+ if (!mailBody) return;
+ mailBody.focus();
+ const sel = window.getSelection();
+ if (sel && sel.rangeCount > 0) {
+ const range = sel.getRangeAt(0);
+ if (mailBody.contains(range.commonAncestorContainer)) {
+ range.deleteContents();
+ if (typeof content === 'string') {
+ const temp = document.createElement('div');
+ temp.innerHTML = content;
+ const frag = document.createDocumentFragment();
+ let node, lastNode;
+ while ((node = temp.firstChild)) {
+ lastNode = frag.appendChild(node);
+ }
+ range.insertNode(frag);
+ if (lastNode) {
+ range.setStartAfter(lastNode);
+ range.collapse(true);
+ sel.removeAllRanges();
+ sel.addRange(range);
+ }
+ } else if (content instanceof Node) {
+ range.insertNode(content);
+ range.setStartAfter(content);
+ range.collapse(true);
+ sel.removeAllRanges();
+ sel.addRange(range);
+ }
+ return;
+ }
+ }
+ if (typeof content === 'string') {
+ mailBody.innerHTML += content;
+ } else if (content instanceof Node) {
+ mailBody.appendChild(content);
+ }
+ }
+
+ function insertEmoji(emoji) {
+ insertContentIntoMailBody(document.createTextNode(emoji));
+ }
async function loadMailUserDetails(msgType, senderId, recipientList, isDirectMail, ccList) {
Data.allUsers = await peopleUtil.sortUsers(rs.userList.users);
@@ -286,31 +345,89 @@ const Layout = () => {
);
}
function sendMail() {
+ // Auto-add inputVal if user typed recipient but didn't click dropdown item
+ ['to', 'cc', 'bcc'].forEach((type) => {
+ const val = Data.recipients[type].inputVal ? Data.recipients[type].inputVal.trim() : '';
+ if (val) {
+ const match = Data.allUsers.find((u) => u.mGroupName && (u.mGroupName.toLowerCase() === val.toLowerCase() || u.mGroupId === val));
+ if (match && !Data.recipients[type].sendList.some((item) => item.mGroupId === match.mGroupId)) {
+ Data.recipients[type].sendList.push(match);
+ } else if (!match && val.length > 5) {
+ Data.recipients[type].sendList.push({ mGroupId: val, mGroupName: val });
+ }
+ Data.recipients[type].inputVal = '';
+ }
+ });
+
const to = Data.recipients.to.sendList.map((toItem) => toItem.mGroupId);
const cc = Data.recipients.cc.sendList.map((ccItem) => ccItem.mGroupId);
const bcc = Data.recipients.bcc.sendList.map((bccItem) => bccItem.mGroupId);
- const { identity: from, subject } = Data;
+
+ let from = Data.identity;
+ if (!from && Data.ownId && Data.ownId.length > 0) {
+ from = Data.ownId[0];
+ Data.identity = from;
+ }
+
+ if (to.length === 0) {
+ widget.popupMessage(
+ m('.widget', [
+ m('.widget__heading', m('h3', 'Missing Recipient')),
+ m('.widget__body', m('p', 'Please select at least one recipient in the "To" field.')),
+ ])
+ );
+ return;
+ }
+
+ if (!from) {
+ widget.popupMessage(
+ m('.widget', [
+ m('.widget__heading', m('h3', 'Missing Identity')),
+ m('.widget__body', m('p', 'Please select a "From" identity.')),
+ ])
+ );
+ return;
+ }
+
+ const subject = Data.subject || '(No Subject)';
const mailBodyElement = document.querySelector('#composerMailBody');
- const mailBody = `
${mailBodyElement.innerHTML}
`;
- rs.rsJsonApiRequest('/rsMail/sendMail', { from, subject, mailBody, to, cc, bcc }).then(
- (res) => {
- if (res.body.retval) {
- Object.keys(Data.recipients).forEach((recipientType) => {
- Data.recipients[recipientType].sendList = [];
- });
- Data.subject = '';
- mailBodyElement.innerHTML = '';
- v.attrs.setShowCompose(false);
- }
- const success = res.body.retval === 1;
- widget.popupMessage(
- m('.widget', [
- m('.widget__heading', m('h3', success ? 'Success' : 'Error')),
- m('.widget__body', m('p', success ? 'Mail sent successfully' : res.body.errorMsg)),
- ])
- );
+ let fullMailBody = mailBodyElement ? mailBodyElement.innerHTML : '';
+
+ if (attachments.length > 0) {
+ const attHtml = `
+
Attachments (${attachments.length}):
+
+ ${attachments.map(att => `- 📎 ${att.name} (${att.size})
`).join('')}
+
+ `;
+ fullMailBody += attHtml;
+ }
+
+ const mailBody = `${fullMailBody}
`;
+
+ rs.rsJsonApiRequest('/rsMail/sendMail', { from, subject, mailBody, to, cc, bcc }, (data, success) => {
+ const isOk = success && data && (
+ data.retval > 0 ||
+ data.retval === true ||
+ (Array.isArray(data.trackingIds) && data.trackingIds.length > 0)
+ );
+ if (isOk) {
+ Object.keys(Data.recipients).forEach((recipientType) => {
+ Data.recipients[recipientType].sendList = [];
+ });
+ Data.subject = '';
+ if (mailBodyElement) mailBodyElement.innerHTML = '';
+ attachments = [];
+ v.attrs.setShowCompose(false);
}
- );
+ widget.popupMessage(
+ m('.widget', [
+ m('.widget__heading', m('h3', isOk ? 'Success' : 'Error')),
+ m('.widget__body', m('p', isOk ? 'Mail sent successfully' : (data?.errorMsg || data?.errorMessage || 'Failed to send mail'))),
+ ])
+ );
+ m.redraw();
+ });
}
return m('.widget', [
m('.widget__heading', m('h3', 'Compose a mail')),
@@ -453,6 +570,67 @@ const Layout = () => {
value: Data.subject,
oninput: (e) => (Data.subject = e.target.value),
}),
+
+ // Hidden File Inputs
+ m('input#mail-file-attach[type=file]', {
+ style: 'display: none;',
+ multiple: true,
+ onchange: (e) => {
+ const files = Array.from(e.target.files || []);
+ files.forEach((file) => {
+ attachments.push({
+ name: file.name,
+ size: formatFileSize(file.size),
+ type: file.type,
+ rawFile: file,
+ });
+ });
+ e.target.value = '';
+ m.redraw();
+ },
+ }),
+ m('input#mail-image-attach[type=file]', {
+ style: 'display: none;',
+ accept: 'image/*',
+ onchange: (e) => {
+ const file = e.target.files && e.target.files[0];
+ if (file) {
+ const reader = new FileReader();
+ reader.onload = (event) => {
+ const src = event.target.result;
+ insertContentIntoMailBody(`
`);
+ };
+ reader.readAsDataURL(file);
+ }
+ e.target.value = '';
+ m.redraw();
+ },
+ }),
+
+ // File Attachments Bar
+ attachments.length > 0 &&
+ m('.mail-attachments-bar', {
+ style: 'margin: 0.5rem 0; padding: 0.5rem 0.75rem; background: #f8fafc; border: 1px solid #cbd5e1; border-radius: 0.375rem; display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center;'
+ }, [
+ m('span', { style: 'font-weight: 600; font-size: 0.85rem; color: #475569; display: flex; align-items: center; gap: 0.35rem; margin-right: 0.25rem;' }, [
+ m('i.fas.fa-paperclip', { style: 'color: #019DFF;' }),
+ `Attachments (${attachments.length}):`
+ ]),
+ attachments.map((att, index) =>
+ m('.mail-attachment-chip', {
+ style: 'display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.25rem 0.65rem; background: #ffffff; border: 1px solid #cbd5e1; border-radius: 1rem; font-size: 0.825rem; font-weight: 500; color: #1e293b; box-shadow: 0 1px 2px rgba(0,0,0,0.05);'
+ }, [
+ m('i.fas.fa-file-alt', { style: 'color: #3b82f6;' }),
+ m('span', att.name),
+ m('span', { style: 'color: #94a3b8; font-size: 0.75rem;' }, `(${att.size})`),
+ m('i.fas.fa-times', {
+ style: 'cursor: pointer; color: #ef4444; margin-left: 0.2rem; font-size: 0.8rem;',
+ onclick: () => attachments.splice(index, 1),
+ })
+ ])
+ )
+ ]),
+
m('.compose-mail__message', [
m('.compose-mail__message-body[placeholder=Message][contenteditable]#composerMailBody', {
oncreate: (vnode) => {
@@ -461,10 +639,94 @@ const Layout = () => {
}
}
}),
- ]),
- m('button.compose-mail__send-btn', { onclick: sendMail }, [
- m('span', 'Send Mail'),
- m('i.fas.fa-paper-plane'),
+
+ // Modern Mail Composer Bottom Toolbar
+ m('.mail-compose-toolbar', {
+ style: 'display: flex; align-items: center; justify-content: space-between; padding: 0.5rem 0.75rem; background: #ffffff; border: 1px solid #cbd5e1; border-top: 1px solid #e2e8f0; border-radius: 0 0 0.375rem 0.375rem; position: relative;'
+ }, [
+ m('.toolbar-left', { style: 'display: flex; align-items: center; gap: 0.5rem;' }, [
+ m('button.mail-compose-send-btn', {
+ style: 'display: flex; align-items: center; gap: 0.5rem; padding: 0.45rem 1.25rem; background: #019DFF; color: #ffffff; border: none; border-radius: 1.5rem; font-weight: 600; font-size: 0.9rem; cursor: pointer; transition: background 0.15s ease; box-shadow: 0 2px 4px rgba(1,157,255,0.25);',
+ onclick: sendMail,
+ }, [
+ m('span', 'Send'),
+ m('i.fas.fa-paper-plane', { style: 'font-size: 0.85rem;' }),
+ ]),
+ m('.toolbar-divider', { style: 'width: 1px; height: 22px; background: #cbd5e1; margin: 0 0.25rem;' }),
+ m('button.mail-tool-btn', {
+ type: 'button',
+ title: 'Attach files',
+ style: 'width: 34px; height: 34px; border-radius: 50%; border: none; background: transparent; color: #475569; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: background 0.15s ease;',
+ onmouseenter: (e) => (e.currentTarget.style.background = '#f1f5f9'),
+ onmouseleave: (e) => (e.currentTarget.style.background = 'transparent'),
+ onclick: () => {
+ const input = document.getElementById('mail-file-attach');
+ if (input) input.click();
+ },
+ }, m('i.fas.fa-paperclip', { style: 'font-size: 1.05rem;' })),
+ m('button.mail-tool-btn', {
+ type: 'button',
+ title: 'Insert image',
+ style: 'width: 34px; height: 34px; border-radius: 50%; border: none; background: transparent; color: #475569; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: background 0.15s ease;',
+ onmouseenter: (e) => (e.currentTarget.style.background = '#f1f5f9'),
+ onmouseleave: (e) => (e.currentTarget.style.background = 'transparent'),
+ onclick: () => {
+ const input = document.getElementById('mail-image-attach');
+ if (input) input.click();
+ },
+ }, m('i.fas.fa-image', { style: 'font-size: 1.05rem;' })),
+ m('button.mail-tool-btn', {
+ type: 'button',
+ title: 'Insert emoji',
+ style: `width: 34px; height: 34px; border-radius: 50%; border: none; background: ${showEmojiPicker ? '#e0f2fe' : 'transparent'}; color: ${showEmojiPicker ? '#0284c7' : '#475569'}; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: background 0.15s ease;`,
+ onclick: () => (showEmojiPicker = !showEmojiPicker),
+ }, m('i.fas.fa-smile', { style: 'font-size: 1.05rem;' })),
+ ]),
+
+ // Floating Emoji Picker Popover
+ showEmojiPicker && m('.mail-emoji-picker-popover', {
+ style: 'position: absolute; bottom: 50px; left: 130px; background: #ffffff; border: 1px solid #cbd5e1; border-radius: 0.5rem; box-shadow: 0 10px 25px -5px rgba(0,0,0,0.15), 0 8px 10px -6px rgba(0,0,0,0.1); width: 320px; max-height: 340px; z-index: 2000; display: flex; flex-direction: column; overflow: hidden;',
+ onclick: (e) => e.stopPropagation(),
+ }, [
+ m('.emoji-search-bar', { style: 'padding: 0.5rem; border-bottom: 1px solid #f1f5f9; display: flex; align-items: center; gap: 0.5rem;' }, [
+ m('i.fas.fa-search', { style: 'color: #94a3b8; font-size: 0.85rem;' }),
+ m('input[type=text][placeholder=Search emoji...]', {
+ style: 'border: none; outline: none; width: 100%; font-size: 0.85rem;',
+ value: emojiSearch,
+ oninput: (e) => (emojiSearch = e.target.value),
+ }),
+ emojiSearch && m('i.fas.fa-times', {
+ style: 'cursor: pointer; color: #94a3b8; font-size: 0.85rem;',
+ onclick: () => (emojiSearch = ''),
+ }),
+ ]),
+ !emojiSearch && m('.emoji-cat-bar', { style: 'display: flex; background: #f8fafc; border-bottom: 1px solid #e2e8f0; padding: 0.25rem; overflow-x: auto;' },
+ chatEmoji.EMOJI_CATEGORIES.map(c =>
+ m('button', {
+ style: `border: none; background: ${c === emojiCategory ? '#ffffff' : 'transparent'}; border-radius: 0.25rem; padding: 0.3rem 0.4rem; cursor: pointer; font-size: 1rem; box-shadow: ${c === emojiCategory ? '0 1px 2px rgba(0,0,0,0.1)' : 'none'};`,
+ title: c,
+ onclick: () => (emojiCategory = c),
+ }, chatEmoji.EMOJI_ICONS[c])
+ )
+ ),
+ m('.emoji-grid-body', { style: 'padding: 0.5rem; display: grid; grid-template-columns: repeat(7, 1fr); gap: 0.25rem; max-height: 230px; overflow-y: auto;' },
+ (emojiSearch
+ ? Object.values(chatEmoji.EMOJI_DATA).flat().filter(e => e.includes(emojiSearch))
+ : (chatEmoji.EMOJI_DATA[emojiCategory] || [])
+ ).map(e =>
+ m('button', {
+ style: 'border: none; background: transparent; font-size: 1.25rem; cursor: pointer; padding: 0.25rem; border-radius: 0.25rem; transition: background 0.15s ease;',
+ onmouseenter: (ev) => (ev.currentTarget.style.background = '#f1f5f9'),
+ onmouseleave: (ev) => (ev.currentTarget.style.background = 'transparent'),
+ onclick: () => {
+ insertEmoji(e);
+ showEmojiPicker = false;
+ },
+ }, e)
+ )
+ ),
+ ])
+ ]),
]),
]),
]);