IMAP email client
Node-RED IMAP based webclient. Configure credentials in the Mail client group to get started.
[{"id":"488dae2d710e1383","type":"subflow","name":"http auth","info":"","category":"network","in":[{"x":60,"y":80,"wires":[{"id":"14d593620c3fa8bc"}]}],"out":[{"x":380,"y":60,"wires":[{"id":"14d593620c3fa8bc","port":0}]}],"env":[{"name":"USERNAME","type":"str","value":"","ui":{"icon":"font-awesome/fa-user-circle-o","label":{"en-US":"Username"}}},{"name":"PASSWORD","type":"env","value":"","ui":{"icon":"font-awesome/fa-user-secret","label":{"en-US":"Password"},"type":"input","opts":{"types":["str","num","bool","json","bin","env","cred"]}}}],"meta":{},"color":"#D7D7A0","icon":"font-awesome/fa-key"},{"id":"14d593620c3fa8bc","type":"function","z":"488dae2d710e1383","name":"authentication","func":"const auth = msg.req.headers.authorization;\n\nif (!auth) {\nnode.warn(msg);\n msg.statusCode = 401;\n if (!msg.headers) {\n msg.headers = {};\n }\n msg.headers[\"WWW-Authenticate\"] = 'Basic realm=\"Secure Area\"';\n msg.payload = \"Authentication required\";\n return [null, msg]; // Second output: unauthorized\n}\n\nconst b64auth = auth.split(' ')[1];\nconst [user, pass] = Buffer.from(b64auth, 'base64').toString().split(':');\n\n// Replace these with your own credentials\nif (user === env.get(\"USERNAME\") && pass === env.get(\"PASSWORD\")) {\n return [msg, null]; // First output: authorized\n} else {\n msg.statusCode = 403;\n msg.payload = \"Access denied\";\n return [null, msg]; // Second output: unauthorized\n}\n","outputs":2,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":220,"y":80,"wires":[[],["35c0b04f129516fc"]],"outputLabels":["ok","unauthorized"]},{"id":"35c0b04f129516fc","type":"http response","z":"488dae2d710e1383","name":"","statusCode":"","headers":{},"x":410,"y":120,"wires":[]},{"id":"b287c7e819841bc2","type":"tab","label":"Support Email (temp)","disabled":false,"info":"","env":[]},{"id":"47e1cbd40e16162f","type":"group","z":"b287c7e819841bc2","name":"Mail client","style":{"label":true},"nodes":["1d53d6fb55ce4ed5","9a04a2b755182436","499dbce85679d62a","3bda59f89e982e1a","c202b29a84f25951","a733f5ed1f29d92f","ab44c373bdac5d5c","3fd8cf4f2a9dc40a","5e6b031d48a2e990","f7f5a41302d82e71","b62da3ad4b9dab9f","29a0e19e85fcee39","e2ef6d44960f40ce","71e6b532fec66ef9","f59db771e1a48345","04e3bd8d34c168b9","52b08f36d3cd00a8","cbba3d707e19b766","c4248d9c601bb29b","5d93f4e8598bf104","ed8f6176129b3bc7","0d438483036a051e","8e5b167df721627c","bd614f296b698e5e","cdd09782ef8a3d07","d6f955a7e39bc94b","7aeb084b6caec4e0","654563b586ddbba4","a3c55706d4733d68","7b1a659a60dd2400","0d39276708f25701","3f09cafd3da91d13"],"env":[{"name":"IMAP_HOST","value":"imap.test.email","type":"str"},{"name":"IMAP_PORT","value":"993","type":"str"},{"name":"IMAP_USER","value":"[email protected]","type":"str"},{"name":"IMAP_PASS","value":"123456","type":"str"},{"name":"USER","value":"test","type":"str"},{"name":"PASS","value":"qwe","type":"str"},{"name":"SMTP_HOST","value":"smtp.test.email","type":"str"},{"name":"SMTP_PORT","value":"465","type":"str"},{"name":"SMTP_USER","value":"[email protected]","type":"str"},{"name":"SMTP_PASS","value":"123456","type":"str"}],"x":14,"y":19,"w":892,"h":422},{"id":"1d53d6fb55ce4ed5","type":"function","z":"b287c7e819841bc2","g":"47e1cbd40e16162f","name":"list messages","func":"const { ImapFlow } = imapflow;\n\nconst client = new ImapFlow({\n host: env.get(\"IMAP_HOST\"),\n port: env.get(\"IMAP_PORT\"),\n secure: true,\n auth: {\n user: env.get(\"IMAP_USER\"),\n pass: env.get(\"IMAP_PASS\")\n }\n});\n\nconst main = async () => {\n await client.connect();\n\n var messages = [];\n\n let lock = await client.getMailboxLock(msg.req.params.folder);\n try {\n let message = await client.fetchOne(client.mailbox.exists, { source: true });\n node.warn(message.source.toString());\n\n for await (let message of client.fetch('1:*', { envelope: true, flags: true })) {\n const flags = message.flags || new Set();\n messages.push({\n uid: message.uid,\n replied: flags.has('\\\\Answered'),\n read: flags.has('\\\\Seen'),\n ...message.envelope\n });\n }\n } finally {\n lock.release();\n }\n\n messages.sort((a, b) => {\n const ad = a.date ? new Date(a.date).getTime() : 0;\n const bd = b.date ? new Date(b.date).getTime() : 0;\n if (bd !== ad) return bd - ad;\n return (b.uid || 0) - (a.uid || 0);\n });\n\n msg.payload = { messages };\n node.send(msg);\n\n await client.logout();\n};\n\nmain().catch(err => node.warn(err));\n\nreturn;\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[{"var":"imapflow","module":"imapflow"}],"x":600,"y":60,"wires":[["499dbce85679d62a"]]},{"id":"9a04a2b755182436","type":"http in","z":"b287c7e819841bc2","g":"47e1cbd40e16162f","name":"","url":"/support/messages/:folder","method":"get","upload":false,"swaggerDoc":"","x":170,"y":60,"wires":[["cdd09782ef8a3d07"]]},{"id":"499dbce85679d62a","type":"http response","z":"b287c7e819841bc2","g":"47e1cbd40e16162f","name":"","statusCode":"","headers":{},"x":830,"y":60,"wires":[]},{"id":"3bda59f89e982e1a","type":"http in","z":"b287c7e819841bc2","g":"47e1cbd40e16162f","name":"","url":"/support","method":"get","upload":false,"swaggerDoc":"","x":110,"y":400,"wires":[["3f09cafd3da91d13"]]},{"id":"c202b29a84f25951","type":"http response","z":"b287c7e819841bc2","g":"47e1cbd40e16162f","name":"","statusCode":"","headers":{},"x":830,"y":400,"wires":[]},{"id":"a733f5ed1f29d92f","type":"template","z":"b287c7e819841bc2","g":"47e1cbd40e16162f","name":"email client","field":"payload","fieldType":"msg","format":"handlebars","syntax":"plain","template":"<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <title>Webmail</title>\n <link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css\">\n <link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/[email protected]/font/bootstrap-icons.css\">\n <link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/[email protected]/dist/quill.snow.css\">\n <script src=\"https://cdn.jsdelivr.net/npm/[email protected]/dist/vue.js\"></script>\n <style>\n html, body { height: 100%; overflow: hidden; }\n #app { height: 100vh; }\n .sidebar, .main-panel, .right-panel { height: 100%; display: flex; flex-direction: column; }\n .sidebar { border-right: 1px solid rgba(0,0,0,.1); }\n .controls { padding: .75rem; border-bottom: 1px solid rgba(0,0,0,.1); }\n .messages-list { flex: 1 1 0; overflow-y: auto; overflow-x: hidden; }\n .message-wrap { flex: 1 1 0; overflow: auto; padding: 1rem; }\n .right-wrap { flex: 1 1 0; overflow: auto; padding: 1rem; }\n .list-item-sub { font-size: .85rem; color: #666; }\n .unread-dot { font-size: .5rem; line-height: 1; }\n .min-w-0 { min-width: 0; }\n .wrap { white-space: pre-wrap; }\n .list-group-item.active .list-item-sub { color: #fff; }\n .list-group-item.active .bi-reply { color: #fff !important; }\n .quill-wrapper { display: flex; flex-direction: column; min-height: 280px; }\n .quill-wrapper .ql-toolbar { flex: 0 0 auto; }\n .quill-wrapper .ql-container { flex: 1 1 auto; height: auto; }\n .original-preview { border-top: 1px solid rgba(0,0,0,.1); padding-top: 1rem; margin-top: .5rem; }\n .forward-headers { font-size: .9rem; border: 1px solid rgba(0,0,0,.1); padding: .5rem .75rem; background: #f8f9fa; }\n .forward-headers dt { width: 4.5rem; }\n .forward-headers dd { margin-left: 5.5rem; }\n .small-muted { font-size: .875rem; color: #6c757d; }\n textarea.merge-json { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", monospace; }\n </style>\n</head>\n<body>\n<div id=\"app\" class=\"container-fluid p-0\">\n <div class=\"row g-0 h-100\">\n <!-- Sidebar -->\n <div class=\"col-12 col-md-4 col-lg-3 sidebar\">\n <div class=\"controls\">\n <div class=\"d-flex gap-2 align-items-center\">\n <select class=\"form-select\" v-model=\"mailbox\" @change=\"onFolderChange\" :disabled=\"loading.folders\">\n <option v-for=\"f in folders\" :key=\"f.path\" :value=\"f.path\">{{ folderLabel(f) }}</option>\n </select>\n <input class=\"form-control\" v-model.trim=\"search\" @keyup.enter=\"loadMessages\" placeholder=\"Search…\" />\n <button class=\"btn btn-outline-secondary\" @click=\"loadMessages\" :disabled=\"loading.list || loading.folders\">Refresh</button>\n <button class=\"btn btn-primary\" @click=\"startNewCompose\" :disabled=\"loading.folders\">New</button>\n <div v-if=\"loading.folders\" class=\"spinner-border spinner-border-sm\" role=\"status\" aria-hidden=\"true\"></div>\n </div>\n <div v-if=\"error\" class=\"alert alert-danger py-2 px-3 mt-2 mb-0\"><strong>Error:</strong> {{ error }}</div>\n </div>\n\n <div class=\"messages-list\">\n <div v-if=\"loading.list\" class=\"d-flex align-items-center justify-content-end px-3 py-2 border-bottom\">\n <div class=\"spinner-border spinner-border-sm\" role=\"status\" aria-hidden=\"true\"></div>\n </div>\n\n <div class=\"list-group list-group-flush\">\n <a v-for=\"m in filteredMessages\" :key=\"m.id\"\n class=\"list-group-item list-group-item-action d-flex align-items-start overflow-hidden\"\n :class=\"{ 'active': selected && selected.id === m.id }\"\n @click.prevent=\"openMessage(m)\">\n <div class=\"me-2 d-flex align-items-center flex-shrink-0\" style=\"width: 14px;\">\n <i v-if=\"!m.read\" class=\"bi bi-circle-fill text-primary unread-dot\"></i>\n </div>\n\n <div class=\"flex-grow-1 min-w-0\">\n <div class=\"d-flex w-100 align-items-center\">\n <h6 class=\"mb-1 me-2 text-truncate\" :class=\"{ 'fw-bold': !m.read }\">\n {{ m.subject || '(no subject)' }}\n </h6>\n <small class=\"text-nowrap flex-shrink-0\">{{ formatDate(m.date) }}</small>\n </div>\n <div class=\"list-item-sub text-truncate\">\n {{ addressLine(m.from) }}\n </div>\n </div>\n\n <div class=\"ms-2 d-flex align-items-center flex-shrink-0\">\n <i v-if=\"m.replied\" class=\"bi bi-reply text-success\" title=\"Replied\"></i>\n </div>\n </a>\n\n <div v-if=\"!loading.list && filteredMessages.length === 0\" class=\"text-muted p-3\">\n No messages found.\n </div>\n </div>\n </div>\n </div>\n\n <!-- Main panel -->\n <div :class=\"['col-12', showRightPanel ? 'col-md-5 col-lg-6' : 'col-md-8 col-lg-9', 'main-panel']\">\n <div class=\"message-wrap\">\n <!-- Big composer -->\n <div v-if=\"composer.show\" class=\"card\">\n <div class=\"card-header d-flex align-items-center justify-content-between\">\n <strong>\n {{ composer.kind === 'reply' ? 'Reply (expanded)' : (composer.kind === 'forward' ? 'Forward (expanded)' : 'New message') }}\n </strong>\n <div class=\"d-flex align-items-center gap-2\">\n <button v-if=\"composer.kind !== 'new'\" class=\"btn btn-sm btn-outline-secondary\" @click=\"minimizeReply\">\n <i class=\"bi bi-arrows-angle-contract me-1\"></i>\n </button>\n <button class=\"btn btn-sm btn-outline-secondary\" @click=\"closeComposer\">\n <i class=\"bi bi-x-lg me-1\"></i>\n </button>\n </div>\n </div>\n <div class=\"card-body d-flex flex-column gap-3\">\n <div class=\"row g-2\">\n <!-- To hidden when Mail Merge ON for new compose -->\n <div class=\"col-12\" v-if=\"!(composer.kind==='new' && mailMerge.enabled)\">\n <label class=\"form-label\">To</label>\n <input class=\"form-control\" v-model.trim=\"composer.to\">\n </div>\n <!-- CC & BCC hidden when Mail Merge ON for new compose -->\n <div class=\"col-12 col-md-6\" v-if=\"!(composer.kind==='new' && mailMerge.enabled)\">\n <label class=\"form-label\">CC</label>\n <input class=\"form-control\" v-model.trim=\"composer.cc\">\n </div>\n <div class=\"col-12 col-md-6\" v-if=\"!(composer.kind==='new' && mailMerge.enabled)\">\n <label class=\"form-label\">BCC</label>\n <input class=\"form-control\" v-model.trim=\"composer.bcc\">\n </div>\n <!-- Subject always shown (placeholders supported) -->\n <div class=\"col-12\">\n <label class=\"form-label\">Subject</label>\n <input class=\"form-control\" v-model.trim=\"composer.subject\">\n </div>\n </div>\n\n <div class=\"quill-wrapper\">\n <div :key=\"'ct-'+composeEditorKey\" ref=\"composeToolbar\">\n <span class=\"ql-formats\">\n <button class=\"ql-bold\"></button>\n <button class=\"ql-italic\"></button>\n <button class=\"ql-underline\"></button>\n </span>\n <span class=\"ql-formats\">\n <button class=\"ql-blockquote\"></button>\n <button class=\"ql-code-block\"></button>\n </span>\n <span class=\"ql-formats\">\n <button class=\"ql-list\" value=\"ordered\"></button>\n <button class=\"ql-list\" value=\"bullet\"></button>\n </span>\n <span class=\"ql-formats\">\n <button class=\"ql-link\"></button>\n <button class=\"ql-clean\"></button>\n </span>\n </div>\n <div :key=\"'ce-'+composeEditorKey\" ref=\"composeEditor\"></div>\n </div>\n\n <div class=\"d-flex align-items-center gap-2\">\n <button class=\"btn btn-primary\"\n :disabled=\"sending || (mailMerge.enabled && mailMerge.running) || !canSendCompose\"\n @click=\"sendCompose\">\n <span v-if=\"sending || mailMerge.running\" class=\"spinner-border spinner-border-sm me-1\"></span>\n {{ mailMerge.enabled ? (mailMerge.running ? `Sending ${mailMerge.sentCount} of ${mailMerge.total}` : 'Send (Mail Merge)') : 'Send' }}\n </button>\n </div>\n\n <div v-if=\"(composer.kind==='reply' || composer.kind==='forward') && selected\" class=\"original-preview\">\n <div v-html=\"originalPreviewHtml\"></div>\n </div>\n\n <div v-if=\"sendError\" class=\"alert alert-danger py-2 px-3 mb-0\">\n <strong>Error:</strong> {{ sendError }}\n </div>\n <div v-if=\"sendOk\" class=\"alert alert-success py-2 px-3 mb-0\">\n Message sent.\n </div>\n </div>\n </div>\n\n <!-- Read message -->\n <div v-else-if=\"selected\" class=\"card\">\n <div class=\"card-body\">\n <div class=\"d-flex align-items-start justify-content-between\">\n <div class=\"pe-3\">\n <h5 class=\"card-title mb-2\">{{ selected.subject || '(no subject)' }}</h5>\n <div class=\"mb-2\">\n <div><strong>From:</strong> {{ addressLine(selected.from) }}</div>\n <div><strong>To:</strong> {{ addressLine(selected.to) || '—' }}</div>\n <div><strong>Date:</strong> {{ formatDate(selected.date, true) }}</div>\n </div>\n </div>\n </div>\n <div>\n <div v-if=\"displayHtml\" v-html=\"displayHtml\"></div>\n <pre v-else class=\"wrap\">{{ displayText || '(no content)' }}</pre>\n </div>\n </div>\n </div>\n\n <div v-else class=\"text-muted p-3\">Select a message from the list to view it.</div>\n </div>\n </div>\n\n <!-- Right panel -->\n <div v-if=\"showRightPanel\" class=\"col-12 col-md-3 col-lg-3 right-panel\">\n <div class=\"right-wrap\">\n <!-- Actions: only when viewing an existing message -->\n <div class=\"card mb-3\" v-if=\"selected\">\n <div class=\"card-header\">Actions</div>\n <div class=\"card-body\">\n <div class=\"d-flex flex-wrap gap-2 mb-3\">\n <button class=\"btn btn-outline-secondary\" :disabled=\"acting.markUnread\" @click=\"markAsUnread\">\n <span v-if=\"acting.markUnread\" class=\"spinner-border spinner-border-sm me-1\"></span>\n Mark as unread\n </button>\n <button class=\"btn btn-outline-danger\" :disabled=\"acting.delete\" @click=\"deleteMessage\">\n <span v-if=\"acting.delete\" class=\"spinner-border spinner-border-sm me-1\"></span>\n Delete\n </button>\n </div>\n\n <div class=\"mb-3\">\n <label class=\"form-label\">Move to folder</label>\n <div class=\"d-flex gap-2\">\n <select class=\"form-select\" v-model=\"moveTarget\">\n <option value=\"\" disabled>Select folder…</option>\n <option v-for=\"f in moveableFolders\" :key=\"f.path\" :value=\"f.path\">{{ folderLabel(f) }}</option>\n </select>\n <button class=\"btn btn-outline-primary\" :disabled=\"!moveTarget || acting.move\" @click=\"moveMessage\">\n <span v-if=\"acting.move\" class=\"spinner-border spinner-border-sm me-1\"></span>\n Move\n </button>\n </div>\n </div>\n\n <div class=\"form-check form-switch\">\n <input class=\"form-check-input\" type=\"checkbox\" id=\"toggleHtml\" v-model=\"viewHtml\">\n <label class=\"form-check-label\" for=\"toggleHtml\">HTML view</label>\n </div>\n </div>\n </div>\n\n <!-- Mail Merge (only when composing NEW) -->\n <div class=\"card\" v-if=\"composer.show && composer.kind==='new'\">\n <div class=\"card-header\">Mail Merge</div>\n <div class=\"card-body\">\n <div class=\"form-check form-switch mb-2\">\n <input class=\"form-check-input\" type=\"checkbox\" id=\"mmToggle\" v-model=\"mailMerge.enabled\">\n <label class=\"form-check-label\" for=\"mmToggle\">Enable mail merge</label>\n </div>\n\n <div v-if=\"mailMerge.enabled\">\n <p class=\"small-muted mb-2\">\n Use JSON array with objects containing at least <code>To</code>. Optional: <code>Name</code>, <code>City</code>, and also\n <code>Cc</code>, <code>Bcc</code>. You can reference any field in the Subject and Body as <code>[Field]</code>.\n </p>\n\n <textarea class=\"form-control merge-json mb-2\" rows=\"7\" v-model=\"mailMerge.jsonText\"></textarea>\n\n <div class=\"d-flex align-items-center gap-2 mb-2\">\n <span class=\"small-muted\" v-if=\"mailMerge.running\">Sending {{ mailMerge.sentCount }} of {{ mailMerge.total }}…</span>\n <span class=\"text-success small-muted\" v-else-if=\"mailMerge.total && !mailMerge.running\">Ready: {{ mailMerge.readyCount }} to send ({{ mailMerge.total }} total rows)</span>\n </div>\n\n <div v-if=\"mailMerge.errors.length\" class=\"alert alert-warning py-2 px-3\">\n <div class=\"fw-bold mb-1\">Some rows failed:</div>\n <ul class=\"mb-0\">\n <li v-for=\"(e,i) in mailMerge.errors\" :key=\"i\">{{ e }}</li>\n </ul>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Small reply/forward editor (hidden when big composer visible) -->\n <div v-if=\"selected && !composer.show\" class=\"card\">\n <div class=\"card-header d-flex align-items-center justify-content-between\">\n <span>Reply</span>\n <div class=\"d-flex align-items-center gap-2\">\n <div class=\"btn-group btn-group-sm\" role=\"group\" aria-label=\"Reply kind\">\n <button type=\"button\" class=\"btn\"\n :class=\"replyKind==='reply' ? 'btn-secondary' : 'btn-outline-secondary'\"\n @click=\"replyKind='reply'\">Reply</button>\n <button type=\"button\" class=\"btn\"\n :class=\"replyKind==='forward' ? 'btn-secondary' : 'btn-outline-secondary'\"\n @click=\"replyKind='forward'\">Forward</button>\n </div>\n <button class=\"btn btn-sm btn-outline-secondary\" @click=\"maximizeReply\">\n <i class=\"bi bi-arrows-angle-expand me-1\"></i>\n </button>\n </div>\n </div>\n <div class=\"card-body d-flex flex-column\">\n <div v-if=\"replyKind==='forward'\" class=\"mb-2\">\n <label class=\"form-label\">To</label>\n <input class=\"form-control\" v-model.trim=\"forwardTo\" placeholder=\"[email protected]\">\n </div>\n\n <div class=\"quill-wrapper mb-2\">\n <div :key=\"'rt-'+replyEditorKey\" ref=\"replyToolbar\">\n <span class=\"ql-formats\">\n <button class=\"ql-bold\"></button>\n <button class=\"ql-italic\"></button>\n <button class=\"ql-underline\"></button>\n </span>\n <span class=\"ql-formats\">\n <button class=\"ql-blockquote\"></button>\n <button class=\"ql-code-block\"></button>\n </span>\n <span class=\"ql-formats\">\n <button class=\"ql-list\" value=\"ordered\"></button>\n <button class=\"ql-list\" value=\"bullet\"></button>\n </span>\n <span class=\"ql-formats\">\n <button class=\"ql-link\"></button>\n <button class=\"ql-clean\"></button>\n </span>\n </div>\n <div :key=\"'re-'+replyEditorKey\" ref=\"replyEditor\"></div>\n </div>\n\n <div class=\"d-flex align-items-center gap-2 mt-auto\">\n <button class=\"btn btn-primary\" :disabled=\"sending || !canSendReply\" @click=\"sendReply\">\n <span v-if=\"sending\" class=\"spinner-border spinner-border-sm me-1\" role=\"status\" aria-hidden=\"true\"></span>\n Send\n </button>\n </div>\n\n <div v-if=\"sendError\" class=\"alert alert-danger mt-2 py-2 px-3 mb-0\">\n <strong>Error:</strong> {{ sendError }}\n </div>\n <div v-if=\"sendOk\" class=\"alert alert-success mt-2 py-2 px-3 mb-0\">\n {{ replyKind==='forward' ? 'Forward sent.' : 'Reply sent.' }}\n </div>\n </div>\n </div>\n </div>\n </div>\n\n </div>\n</div>\n\n<script src=\"https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js\"></script>\n<script src=\"https://cdn.jsdelivr.net/npm/[email protected]/dist/quill.js\"></script>\n\n<script>\nnew Vue({\n el: '#app',\n data: {\n mailbox: '',\n folders: [],\n messages: [],\n selected: null,\n loading: { folders: false, list: false, message: false },\n error: null,\n search: '',\n\n viewHtml: true,\n\n // Quill instances\n quillReply: null,\n quillCompose: null,\n // Force DOM rebuild on editors (fix toolbar collapse on re-mount)\n replyEditorKey: 0,\n composeEditorKey: 0,\n // Preserve draft when toggling small <-> big editor\n replyDraftHtml: '',\n\n sending: false,\n sendError: null,\n sendOk: false,\n\n acting: { markUnread: false, delete: false, move: false },\n moveTarget: '',\n\n // small reply/forward\n replyKind: 'reply', // 'reply' | 'forward'\n forwardTo: '',\n\n // big composer\n composer: {\n show: false,\n kind: 'new', // 'new' | 'reply' | 'forward'\n to: '',\n cc: '',\n bcc: '',\n subject: '',\n replyRef: null\n },\n\n // Mail merge state\n mailMerge: {\n enabled: false,\n running: false,\n jsonText: JSON.stringify([\n {\"To\":\"[email protected]\",\"Name\":\"Example One\",\"City\":\"Tokyo\",\"Cc\":\"[email protected]\",\"Bcc\":\"\"},\n {\"To\":\"[email protected]\",\"Name\":\"Example Two\",\"City\":\"Paris\"}\n ], null, 2),\n total: 0,\n sentCount: 0,\n readyCount: 0,\n errors: []\n }\n },\n computed: {\n showRightPanel() {\n // Show right panel when viewing an email OR composing a new one (for Mail Merge card).\n if (this.composer.show && this.composer.kind === 'new') return true;\n return !!this.selected;\n },\n filteredMessages() {\n const q = this.search.toLowerCase();\n if (!q) return this.messages;\n return this.messages.filter(m =>\n (m.subject || '').toLowerCase().includes(q) ||\n (this.addressLine(m.from) || '').toLowerCase().includes(q)\n );\n },\n displayHtml() {\n if (!this.selected || !this.viewHtml) return '';\n return this.selected.html || '';\n },\n displayText() {\n if (!this.selected || this.viewHtml) return '';\n if (this.selected.text) return this.selected.text;\n if (this.selected.html) return this.htmlToText(this.selected.html);\n return '';\n },\n canSendReply() {\n const t = this.getReplyText().trim();\n if (this.replyKind === 'forward') {\n return !!this.selected && t.length > 0 && (this.forwardTo || '').trim().length > 0;\n }\n return !!this.selected && t.length > 0;\n },\n canSendCompose() {\n const t = this.getComposeText().trim();\n if (!t) return false;\n\n if (this.composer.kind === 'new') {\n if (this.mailMerge.enabled) {\n const rows = this.parseMergeRows();\n return rows.length > 0;\n }\n return t && this.composer.subject.trim() && (this.composer.to || '').trim();\n } else if (this.composer.kind === 'forward') {\n return t && (this.composer.to || '').trim();\n } else { // reply\n return t && !!(this.composer.replyRef);\n }\n },\n moveableFolders() {\n const current = this.mailbox || '';\n return this.folders.filter(f => f.path !== current);\n },\n originalPreviewHtml() {\n if (!this.selected) return '';\n const origHtml = this.selected.html\n ? this.selected.html\n : (this.selected.text ? `<p>${this.escapeHtml(this.selected.text).replace(/\\n{2,}/g, '</p><p>').replace(/\\n/g, '<br>')}</p>` : '');\n\n if (this.composer.kind === 'reply') {\n return `${this.citationHtml(this.selected)}<blockquote style=\"margin:0;padding-left:1em;border-left:3px solid #ccc;\">${origHtml}</blockquote>`;\n }\n if (this.composer.kind === 'forward') {\n return `${this.forwardHeaderHtml(this.selected)}<blockquote style=\"margin:0;padding-left:1em;border-left:3px solid #ccc;\">${origHtml}</blockquote>`;\n }\n return '';\n }\n },\n watch: {\n selected(n) {\n if (n && !this.composer.show) {\n this.$nextTick(() => this.initReplyQuill(true));\n } else {\n this.quillReply = null;\n }\n },\n 'composer.show'(showing) {\n if (showing) {\n // Moving to big editor: capture small draft and rebuild big editor DOM\n this.replyDraftHtml = this.getReplyHTML();\n this.quillReply = null; // drop old instance\n this.composeEditorKey++; // force fresh toolbar/editor DOM\n this.$nextTick(() => {\n this.initComposeQuill(true);\n if (this.quillCompose && this.replyDraftHtml) {\n try { this.quillCompose.clipboard.dangerouslyPasteHTML(this.replyDraftHtml); }\n catch (_) { this.$refs.composeEditor.innerHTML = this.replyDraftHtml; }\n }\n });\n } else if (this.selected) {\n // Coming back to small editor: rebuild and paste whatever draft we kept (if any)\n this.replyEditorKey++; // fresh DOM for small editor\n this.$nextTick(() => {\n this.initReplyQuill(true);\n if (this.quillReply && this.replyDraftHtml) {\n try { this.quillReply.clipboard.dangerouslyPasteHTML(this.replyDraftHtml); }\n catch (_) { this.$refs.replyEditor.innerHTML = this.replyDraftHtml; }\n this.replyDraftHtml = '';\n }\n });\n }\n }\n },\n created() { this.loadFolders(); },\n methods: {\n // ----- Quill helpers -----\n initReplyQuill(reset = false) {\n if (reset) this.quillReply = null;\n if (this.quillReply || !this.$refs.replyEditor) return;\n this.quillReply = new Quill(this.$refs.replyEditor, {\n theme: 'snow',\n modules: { toolbar: this.$refs.replyToolbar }\n });\n },\n initComposeQuill(reset = false) {\n if (reset) this.quillCompose = null;\n if (this.quillCompose || !this.$refs.composeEditor) return;\n this.quillCompose = new Quill(this.$refs.composeEditor, {\n theme: 'snow',\n modules: { toolbar: this.$refs.composeToolbar }\n });\n },\n getReplyHTML() {\n if (!this.quillReply) return '';\n const fn = this.quillReply.getSemanticHTML;\n return (typeof fn === 'function') ? this.quillReply.getSemanticHTML() : this.quillReply.root.innerHTML;\n },\n getReplyText() { return this.quillReply ? this.quillReply.getText() : ''; },\n getComposeHTML() {\n if (!this.quillCompose) return '';\n const fn = this.quillCompose.getSemanticHTML;\n return (typeof fn === 'function') ? this.quillCompose.getSemanticHTML() : this.quillCompose.root.innerHTML;\n },\n getComposeText() { return this.quillCompose ? this.quillCompose.getText() : ''; },\n\n // ----- Compose toggles -----\n startNewCompose() {\n // Unselect current email so Actions panel hides\n this.selected = null;\n\n this.composer.show = true;\n this.composer.kind = 'new';\n this.composer.to = '';\n this.composer.cc = '';\n this.composer.bcc = '';\n this.composer.subject = '';\n this.composer.replyRef = null;\n // fresh DOM/toolbar for big editor\n this.composeEditorKey++;\n this.$nextTick(() => {\n this.initComposeQuill(true);\n if (this.quillCompose) this.quillCompose.setText('');\n });\n },\n maximizeReply() {\n if (!this.selected) return;\n // put small draft into buffer; watcher will paste into big editor\n this.replyDraftHtml = this.getReplyHTML();\n const subj = this.selected.subject || '';\n this.composer.show = true;\n this.composer.kind = (this.replyKind === 'forward') ? 'forward' : 'reply';\n\n if (this.replyKind === 'reply') {\n const from = Array.isArray(this.selected.from) && this.selected.from.length ? this.selected.from[0] : null;\n this.composer.to = from ? (from.address || from.email || '') : '';\n this.composer.subject = /^re:/i.test(subj) ? subj : `Re: ${subj}`;\n this.composer.replyRef = { mailbox: this.mailbox, uid: this.selected.id };\n } else {\n this.composer.to = '';\n this.composer.subject = /^fwd:/i.test(subj) ? subj : `Fwd: ${subj}`;\n this.composer.replyRef = { mailbox: this.mailbox, uid: this.selected.id };\n }\n this.composer.cc = '';\n this.composer.bcc = '';\n },\n minimizeReply() {\n // copy content back to small editor via buffer; watcher will paste\n const html = this.getComposeHTML();\n this.replyDraftHtml = html || '';\n this.composer.show = false;\n },\n closeComposer() {\n // close and clear buffers so small editor comes back blank\n this.replyDraftHtml = '';\n this.composer.show = false;\n this.composer.replyRef = null;\n this.composer.to = this.composer.cc = this.composer.bcc = this.composer.subject = '';\n this.quillCompose = null; // force clean re-init next time\n },\n\n // ----- UI helpers -----\n folderLabel(f) {\n if (f.specialUse && String(f.specialUse).toLowerCase() === '\\\\inbox') return 'Inbox';\n const last = f.name || f.path.split(f.delimiter || '/').pop();\n return last || f.path;\n },\n addressLine(addr) {\n if (!addr) return '';\n const toStr = (a) => {\n if (!a) return '';\n if (typeof a === 'string') return a;\n if (a.address) return a.name ? `${a.name} <${a.address}>` : a.address;\n if (a.email) return a.name ? `${a.name} <${a.email}>` : a.email;\n return a.name || a.toString();\n };\n return Array.isArray(addr) ? addr.map(toStr).join(', ') : toStr(addr);\n },\n formatDate(d, withTime = false) {\n if (!d) return '';\n const dt = new Date(d);\n const opts = withTime\n ? { year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }\n : { year: 'numeric', month: 'short', day: '2-digit' };\n return dt.toLocaleString(undefined, opts);\n },\n\n // ----- Data loading -----\n async loadFolders() {\n this.error = null;\n this.loading.folders = true;\n try {\n const res = await fetch('/support/folders', { method: 'GET' });\n if (!res.ok) throw new Error(`HTTP ${res.status}`);\n const data = await res.json();\n const rows = Array.isArray(data?.folders) ? data.folders : [];\n this.folders = rows.map(r => ({ path: r.path, name: r.name, delimiter: r.delimiter || '/', specialUse: r.specialUse || null }));\n const inbox = this.folders.find(f => (f.specialUse || '').toLowerCase() === '\\\\inbox') ||\n this.folders.find(f => f.path.toUpperCase() === 'INBOX') ||\n this.folders[0];\n if (inbox) this.mailbox = inbox.path;\n if (this.mailbox) await this.loadMessages();\n } catch (e) {\n this.error = e.message || String(e);\n } finally {\n this.loading.folders = false;\n }\n },\n onFolderChange() {\n this.selected = null;\n this.search = '';\n this.composer.show = false;\n this.loadMessages();\n },\n async loadMessages() {\n if (!this.mailbox) return;\n this.error = null;\n this.loading.list = true;\n try {\n const url = `/support/messages/${encodeURIComponent(this.mailbox)}`;\n const res = await fetch(url, { method: 'GET' });\n if (!res.ok) throw new Error(`HTTP ${res.status}`);\n const data = await res.json();\n const rows = Array.isArray(data?.messages) ? data.messages : [];\n this.messages = rows.map(m => ({\n id: m.uid,\n subject: m.subject || '',\n from: m.from || [],\n to: m.to || [],\n date: m.date || null,\n read: !!m.read,\n replied: !!m.replied\n }));\n this.selected = null;\n } catch (e) {\n this.error = e.message || String(e);\n } finally {\n this.loading.list = false;\n }\n },\n\n // ----- Read/open message -----\n async openMessage(item) {\n if (!item) return;\n this.composer.show = false;\n this.loading.message = true; this.error = null;\n try {\n const url = `/support/message/${encodeURIComponent(this.mailbox)}/${encodeURIComponent(item.id)}`;\n const res = await fetch(url, { method: 'GET' });\n if (!res.ok) throw new Error(`HTTP ${res.status}`);\n const msg = await res.json();\n this.selected = Object.assign({}, item, {\n html: msg.html || null,\n text: msg.text || null,\n from: msg.from || item.from,\n to: msg.to || item.to,\n date: msg.date || item.date,\n read: true\n });\n const idx = this.messages.findIndex(x => x.id === item.id);\n if (idx !== -1 && !this.messages[idx].read) {\n this.$set(this.messages, idx, Object.assign({}, this.messages[idx], { read: true }));\n }\n if (this.quillReply) this.quillReply.setText('');\n this.sendError = null;\n this.sendOk = false;\n // force a clean mount of small editor after message switch\n this.replyEditorKey++;\n this.$nextTick(() => this.initReplyQuill(true));\n } catch (e) {\n this.error = e.message || String(e);\n } finally {\n this.loading.message = false;\n }\n },\n\n // ----- Helpers for preview/quoting -----\n escapeHtml(s) {\n return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');\n },\n htmlToText(html) {\n if (!html) return '';\n let s = String(html);\n s = s.replace(/<br\\s*\\/?>/gi, '\\n');\n s = s.replace(/<\\/(p|div|li|h[1-6]|tr)>/gi, '\\n');\n s = s.replace(/<li[^>]*>/gi, ' • ');\n s = s.replace(/<style[\\s\\S]*?<\\/style>/gi, '');\n s = s.replace(/<script[\\s\\S]*?<\\/script>/gi, '');\n s = s.replace(/<[^>]+>/g, '');\n const ta = document.createElement('textarea');\n ta.innerHTML = s;\n return ta.value;\n },\n quotePlain(text) {\n return String(text || '')\n .split(/\\r?\\n/)\n .map(line => (line.length ? `> ${line}` : '>'))\n .join('\\n');\n },\n citationText(sel) {\n const from = Array.isArray(sel.from) && sel.from.length ? sel.from[0] : null;\n const who = from ? (from.name ? `${from.name}${from.address ? ' <'+from.address+'>' : ''}` : (from.address || 'sender')) : 'sender';\n const when = sel.date ? new Date(sel.date).toLocaleString() : '';\n return `On ${when}, ${who} wrote:`;\n },\n citationHtml(sel) {\n const from = Array.isArray(sel.from) && sel.from.length ? sel.from[0] : null;\n const name = from && from.name ? this.escapeHtml(from.name) : '';\n const addr = from && from.address ? `<${this.escapeHtml(from.address)}>` : '';\n const who = (name || addr) ? `${name}${name && addr ? ' ' : ''}${addr}` : 'sender';\n const when = sel.date ? this.escapeHtml(new Date(sel.date).toLocaleString()) : '';\n return `<p>On ${when}, ${who} wrote:</p>`;\n },\n forwardHeaderHtml(sel) {\n const from = this.addressLine(sel.from) || '';\n const to = this.addressLine(sel.to) || '';\n const subj = sel.subject || '';\n const when = sel.date ? new Date(sel.date).toLocaleString() : '';\n return `\n <div class=\"mb-2\"><strong>----- Forwarded message -----</strong></div>\n <dl class=\"forward-headers\">\n <dt>From:</dt><dd>${this.escapeHtml(from)}</dd>\n <dt>Date:</dt><dd>${this.escapeHtml(when)}</dd>\n <dt>Subject:</dt><dd>${this.escapeHtml(subj)}</dd>\n <dt>To:</dt><dd>${this.escapeHtml(to)}</dd>\n </dl>\n `;\n },\n buildReplyBodiesFrom(htmlInput) {\n if (!this.selected) return { text: '', html: '' };\n const userHtml = htmlInput || '';\n const userText = (new DOMParser().parseFromString(userHtml, 'text/html').documentElement.textContent) || '';\n const origText = this.selected.text || this.htmlToText(this.selected.html || '');\n const quotedText = this.quotePlain(origText);\n const text = `${userText}\\n\\n${this.citationText(this.selected)}\\n\\n${quotedText}`.trim();\n const origHtml = this.selected.html\n ? this.selected.html\n : (origText ? `<p>${this.escapeHtml(origText).replace(/\\n{2,}/g, '</p><p>').replace(/\\n/g, '<br>')}</p>` : '');\n const html = `${userHtml}${userHtml ? '<hr>' : ''}${this.citationHtml(this.selected)}<blockquote style=\"margin:0;padding-left:1em;border-left:3px solid #ccc;\">${origHtml}</blockquote>`;\n return { text, html };\n },\n buildForwardBodiesFrom(userHtml) {\n if (!this.selected) return { text: '', html: '' };\n const uHtml = userHtml || '';\n const userText = (new DOMParser().parseFromString(uHtml, 'text/html').documentElement.textContent) || '';\n const from = this.addressLine(this.selected.from) || '';\n const to = this.addressLine(this.selected.to) || '';\n const subj = this.selected.subject || '';\n const when = this.selected.date ? new Date(this.selected.date).toLocaleString() : '';\n\n const origText = this.selected.text || this.htmlToText(this.selected.html || '');\n const headerText = [\n '----- Forwarded message -----',\n `From: ${from}`,\n `Date: ${when}`,\n `Subject: ${subj}`,\n `To: ${to}`\n ].join('\\n');\n const text = `${userText}\\n\\n${headerText}\\n\\n${origText}`.trim();\n\n const origHtml = this.selected.html\n ? this.selected.html\n : (origText ? `<p>${this.escapeHtml(origText).replace(/\\n{2,}/g, '</p><p>').replace(/\\n/g, '<br>')}</p>` : '');\n const headerHtml = this.forwardHeaderHtml(this.selected);\n const html = `${uHtml}${uHtml ? '<hr>' : ''}${headerHtml}<blockquote style=\"margin:0;padding-left:1em;border-left:3px solid #ccc;\">${origHtml}</blockquote>`;\n return { text, html };\n },\n\n // ----- Actions -----\n async markAsUnread() {\n if (!this.selected) return;\n this.acting.markUnread = true;\n try {\n const url = `/support/message/${encodeURIComponent(this.mailbox)}/${encodeURIComponent(this.selected.id)}/read`;\n const res = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ read: false })\n });\n if (!res.ok) throw new Error(`HTTP ${res.status}`);\n const idx = this.messages.findIndex(x => x.id === this.selected.id);\n if (idx !== -1) this.$set(this.messages, idx, Object.assign({}, this.messages[idx], { read: false }));\n this.selected = Object.assign({}, this.selected, { read: false });\n } catch (e) {\n this.error = e.message || String(e);\n } finally {\n this.acting.markUnread = false;\n }\n },\n async deleteMessage() {\n if (!this.selected) return;\n this.acting.delete = true;\n try {\n const url = `/support/message/${encodeURIComponent(this.mailbox)}/${encodeURIComponent(this.selected.id)}/delete`;\n const res = await fetch(url, { method: 'POST' });\n if (!res.ok) throw new Error(`HTTP ${res.status}`);\n const idx = this.messages.findIndex(x => x.id === this.selected.id);\n if (idx !== -1) this.messages.splice(idx, 1);\n this.selected = null;\n this.moveTarget = '';\n this.sendError = null;\n this.sendOk = false;\n this.quillReply = null;\n } catch (e) {\n this.error = e.message || String(e);\n } finally {\n this.acting.delete = false;\n }\n },\n async moveMessage() {\n if (!this.selected || !this.moveTarget) return;\n this.acting.move = true;\n try {\n const url = `/support/message/${encodeURIComponent(this.mailbox)}/${encodeURIComponent(this.selected.id)}/move`;\n const res = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ target: this.moveTarget })\n });\n if (!res.ok) throw new Error(`HTTP ${res.status}`);\n const idx = this.messages.findIndex(x => x.id === this.selected.id);\n if (idx !== -1) this.messages.splice(idx, 1);\n this.selected = null;\n this.moveTarget = '';\n this.sendError = null;\n this.sendOk = false;\n this.quillReply = null;\n } catch (e) {\n this.error = e.message || String(e);\n } finally {\n this.acting.move = false;\n }\n },\n\n // ----- Send (small editor) -----\n async sendReply() {\n if (!this.selected) return;\n const plain = this.getReplyText().trim();\n if (!plain || plain === '\\n') return;\n\n this.sending = true;\n this.sendError = null;\n this.sendOk = false;\n\n try {\n const userHtml = this.getReplyHTML();\n let payload;\n\n if (this.replyKind === 'reply') {\n const bodies = this.buildReplyBodiesFrom(userHtml);\n payload = {\n mode: 'reply',\n reply: { mailbox: this.mailbox, uid: this.selected.id },\n saveToSent: true,\n text: bodies.text,\n html: bodies.html\n };\n } else {\n const subj = this.selected?.subject || '';\n const fwdSubj = /^fwd:/i.test(subj) ? subj : `Fwd: ${subj}`;\n const bodies = this.buildForwardBodiesFrom(userHtml);\n payload = {\n mode: 'new',\n saveToSent: true,\n to: (this.forwardTo || '').trim(),\n subject: fwdSubj,\n text: bodies.text,\n html: bodies.html\n };\n }\n\n const res = await fetch('/support/message', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(payload)\n });\n\n if (!res.ok) {\n let err = `HTTP ${res.status}`;\n try {\n const body = await res.json();\n if (body && body.error) err = body.error;\n } catch (_) {}\n throw new Error(err);\n }\n\n this.sendOk = true;\n if (this.quillReply) this.quillReply.setText('');\n if (this.replyKind === 'reply') {\n const idx = this.messages.findIndex(x => x.id === this.selected.id);\n if (idx !== -1 && !this.messages[idx].replied) {\n this.$set(this.messages, idx, Object.assign({}, this.messages[idx], { replied: true }));\n }\n if (!this.selected.replied) {\n this.selected = Object.assign({}, this.selected, { replied: true });\n }\n }\n } catch (e) {\n this.sendError = e.message || String(e);\n } finally {\n this.sending = false;\n if (this.sendOk) setTimeout(() => (this.sendOk = false), 2000);\n }\n },\n\n // ----- Mail Merge helpers -----\n parseMergeRows() {\n try {\n const arr = JSON.parse(this.mailMerge.jsonText || '[]');\n if (!Array.isArray(arr)) return [];\n // normalize keys: keep as-is but ensure objects\n return arr.filter(r => r && typeof r === 'object')\n .map(r => Object.assign({}, r));\n } catch (e) {\n return [];\n }\n },\n replacePlaceholders(str, rec) {\n if (!str) return '';\n return String(str).replace(/\\[([A-Za-z0-9_]+)\\]/g, (m, p1) => {\n const key = p1;\n const val = rec.hasOwnProperty(key) ? rec[key] : '';\n return (val == null) ? '' : String(val);\n });\n },\n async delay(ms) {\n await new Promise(r => setTimeout(r, ms));\n },\n\n // ----- Send (big editor) -----\n async sendCompose() {\n if (!this.composer.show) return;\n\n // Handle Mail Merge for brand new emails\n if (this.composer.kind === 'new' && this.mailMerge.enabled) {\n return this.runMailMerge();\n }\n\n const plain = this.getComposeText().trim();\n if (!plain || plain === '\\n') return;\n\n this.sending = true;\n this.sendError = null;\n this.sendOk = false;\n\n try {\n const html = this.getComposeHTML();\n let payload;\n\n if (this.composer.kind === 'new') {\n payload = {\n mode: 'new',\n saveToSent: true,\n to: this.composer.to || undefined,\n cc: this.composer.cc || undefined,\n bcc: this.composer.bcc || undefined,\n subject: this.composer.subject || '',\n text: (new DOMParser().parseFromString(html, 'text/html').documentElement.textContent) || '',\n html\n };\n } else if (this.composer.kind === 'reply') {\n const bodies = this.buildReplyBodiesFrom(html);\n payload = {\n mode: 'reply',\n reply: this.composer.replyRef,\n saveToSent: true,\n to: this.composer.to || undefined,\n cc: this.composer.cc || undefined,\n bcc: this.composer.bcc || undefined,\n subject: this.composer.subject || undefined,\n text: bodies.text,\n html: bodies.html\n };\n } else { // forward\n const subj = this.selected?.subject || '';\n const fwdSubj = this.composer.subject || (/^fwd:/i.test(subj) ? subj : `Fwd: ${subj}`);\n const bodies = this.buildForwardBodiesFrom(html);\n payload = {\n mode: 'new',\n saveToSent: true,\n to: this.composer.to || undefined,\n cc: this.composer.cc || undefined,\n bcc: this.composer.bcc || undefined,\n subject: fwdSubj,\n text: bodies.text,\n html: bodies.html\n };\n }\n\n const res = await fetch('/support/message', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(payload)\n });\n\n if (!res.ok) {\n let err = `HTTP ${res.status}`;\n try {\n const body = await res.json();\n if (body && body.error) err = body.error;\n } catch (_) {}\n throw new Error(err);\n }\n\n this.sendOk = true;\n\n if (this.composer.kind === 'reply' && this.selected) {\n const idx = this.messages.findIndex(x => x.id === this.selected.id);\n if (idx !== -1 && !this.messages[idx].replied) {\n this.$set(this.messages, idx, Object.assign({}, this.messages[idx], { replied: true }));\n }\n if (!this.selected.replied) {\n this.selected = Object.assign({}, this.selected, { replied: true });\n }\n }\n\n // clear buffer so small editor reappears blank\n this.replyDraftHtml = '';\n this.closeComposer();\n } catch (e) {\n this.sendError = e.message || String(e);\n } finally {\n this.sending = false;\n if (this.sendOk) setTimeout(() => (this.sendOk = false), 1500);\n }\n },\n\n async runMailMerge() {\n // Parse rows\n const rows = this.parseMergeRows();\n if (!rows.length) {\n this.sendError = 'Mail merge JSON is empty or invalid.';\n return;\n }\n\n const baseHtml = this.getComposeHTML();\n const baseText = (new DOMParser().parseFromString(baseHtml, 'text/html').documentElement.textContent) || '';\n const baseSubject = this.composer.subject || '';\n\n // Compute pending rows (skip Sent:true)\n const pending = rows.filter(r => !r.Sent);\n this.mailMerge.total = rows.length;\n this.mailMerge.readyCount = pending.length;\n this.mailMerge.sentCount = 0;\n this.mailMerge.errors = [];\n this.mailMerge.running = true;\n this.sending = true;\n this.sendError = null;\n this.sendOk = false;\n\n try {\n for (let i = 0; i < rows.length; i++) {\n const r = rows[i];\n if (r.Sent) continue; // skip already sent\n\n // Prepare message per row\n const to = (r.To || '').trim();\n if (!to) {\n this.mailMerge.errors.push(`Row ${i+1}: missing \"To\".`);\n continue;\n }\n\n const rowHtml = this.replacePlaceholders(baseHtml, r);\n const rowText = this.replacePlaceholders(baseText, r);\n const rowSubject = this.replacePlaceholders(baseSubject, r);\n\n // Optional per-row Cc/Bcc (placeholders allowed)\n const rowCc = r.Cc ? this.replacePlaceholders(String(r.Cc), r) : undefined;\n const rowBcc = r.Bcc ? this.replacePlaceholders(String(r.Bcc), r) : undefined;\n\n // Build payload\n const payload = {\n mode: 'new',\n saveToSent: true,\n to,\n cc: rowCc || undefined,\n bcc: rowBcc || undefined,\n subject: rowSubject,\n text: rowText,\n html: rowHtml\n };\n\n // Send\n try {\n const res = await fetch('/support/message', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(payload)\n });\n if (!res.ok) {\n let err = `HTTP ${res.status}`;\n try {\n const body = await res.json();\n if (body && body.error) err = body.error;\n } catch (_) {}\n throw new Error(err);\n }\n // Mark as sent\n r.Sent = true;\n this.mailMerge.sentCount++;\n } catch (e) {\n this.mailMerge.errors.push(`Row ${i+1} (${to}): ${e.message || e}`);\n }\n\n // Update textarea JSON so user sees Sent flags live\n try {\n this.mailMerge.jsonText = JSON.stringify(rows, null, 2);\n } catch (_) {}\n\n // 2-second delay between emails\n await this.delay(2000);\n }\n\n // Done\n this.sendOk = this.mailMerge.errors.length === 0;\n } catch (e) {\n this.sendError = e.message || String(e);\n } finally {\n this.mailMerge.running = false;\n this.sending = false;\n if (this.sendOk) setTimeout(() => (this.sendOk = false), 1500);\n }\n }\n }\n});\n</script>\n</body>\n</html>\n","output":"str","x":590,"y":400,"wires":[["c202b29a84f25951"]]},{"id":"ab44c373bdac5d5c","type":"http in","z":"b287c7e819841bc2","g":"47e1cbd40e16162f","name":"","url":"/support/message/:folder/:uid","method":"get","upload":false,"skipBodyParsing":false,"swaggerDoc":"","x":180,"y":100,"wires":[["d6f955a7e39bc94b"]]},{"id":"3fd8cf4f2a9dc40a","type":"function","z":"b287c7e819841bc2","g":"47e1cbd40e16162f","name":"get message","func":"const { ImapFlow } = imapflow;\n\nconst client = new ImapFlow({\n host: env.get(\"IMAP_HOST\"),\n port: env.get(\"IMAP_PORT\"),\n secure: true,\n auth: {\n user: env.get(\"IMAP_USER\"),\n pass: env.get(\"IMAP_PASS\")\n }\n});\n\n// -- helpers ---------------------------------------------------\nasync function streamToString(stream) {\n const chunks = [];\n for await (const chunk of stream) chunks.push(chunk);\n return Buffer.concat(chunks).toString('utf8');\n}\n\nfunction findPart(part, type, subtype) {\n if (!part) return null;\n const t = (part.type || '').toLowerCase(); // e.g. \"text/plain\" of \"text/html\"\n const wanted = [type, subtype].filter(Boolean).join('/').toLowerCase();\n if (t === wanted) return part;\n if (Array.isArray(part.childNodes)) {\n for (const p of part.childNodes) {\n const found = findPart(p, type, subtype);\n if (found) return found;\n }\n }\n if (Array.isArray(part.parts)) { // oudere veldnaam in sommige servers\n for (const p of part.parts) {\n const found = findPart(p, type, subtype);\n if (found) return found;\n }\n }\n return null;\n}\n\nconst main = async () => {\n await client.connect();\n\n const mailbox = (msg.req.params?.folder || 'INBOX').toString();\n const uid = Number(msg.req.params?.uid ?? msg.req.params?.id);\n if (!Number.isFinite(uid)) {\n msg.statusCode = 400;\n msg.payload = { error: 'Missing or invalid uid' };\n node.send(msg);\n await client.logout();\n return;\n }\n\n let lock = await client.getMailboxLock(mailbox);\n try {\n const m = await client.fetchOne(uid, { envelope: true, bodyStructure: true }, { uid: true });\n if (!m) {\n msg.statusCode = 404;\n msg.payload = { error: 'Message not found' };\n node.send(msg);\n return;\n }\n\n const htmlPart = findPart(m.bodyStructure, 'text', 'html');\n const textPart = findPart(m.bodyStructure, 'text', 'plain');\n\n let html = null;\n let text = null;\n\n if (htmlPart && htmlPart.part) {\n const { content } = await client.download(uid, htmlPart.part, { uid: true });\n html = await streamToString(content);\n }\n if (textPart && textPart.part) {\n const { content } = await client.download(uid, textPart.part, { uid: true });\n text = await streamToString(content);\n }\n\n // mark as read (\\Seen) using UID\n try {\n await client.messageFlagsAdd(uid, ['\\\\Seen'], { uid: true });\n } catch (e) {\n node.warn(`Could not set \\\\Seen for UID ${uid}: ${e.message}`);\n }\n\n const env = m.envelope || {};\n msg.payload = {\n uid: m.uid || uid,\n date: env.date || null,\n subject: env.subject || null,\n from: env.from || [],\n to: env.to || [],\n replyTo: env.replyTo || [],\n sender: env.sender || [],\n inReplyTo: env.inReplyTo || null,\n messageId: env.messageId || null,\n html,\n text,\n read: true\n };\n node.send(msg);\n } finally {\n lock.release();\n }\n\n await client.logout();\n};\n\nmain().catch(err => {\n node.error(err);\n msg.statusCode = 500;\n msg.payload = { error: err.message };\n node.send(msg);\n});\nreturn;\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[{"var":"imapflow","module":"imapflow"}],"x":590,"y":100,"wires":[["5e6b031d48a2e990"]]},{"id":"5e6b031d48a2e990","type":"http response","z":"b287c7e819841bc2","g":"47e1cbd40e16162f","name":"","statusCode":"","headers":{},"x":830,"y":100,"wires":[]},{"id":"f7f5a41302d82e71","type":"http in","z":"b287c7e819841bc2","g":"47e1cbd40e16162f","name":"","url":"/support/folders","method":"get","upload":false,"skipBodyParsing":false,"swaggerDoc":"","x":130,"y":320,"wires":[["7b1a659a60dd2400"]]},{"id":"b62da3ad4b9dab9f","type":"function","z":"b287c7e819841bc2","g":"47e1cbd40e16162f","name":"get folders","func":"const { ImapFlow } = imapflow;\n\nconst client = new ImapFlow({\n host: env.get(\"IMAP_HOST\"),\n port: env.get(\"IMAP_PORT\"),\n secure: true,\n auth: {\n user: env.get(\"IMAP_USER\"),\n pass: env.get(\"IMAP_PASS\")\n }\n});\n\n// Recursief: zet BigInt -> string; Sets -> arrays\nfunction sanitizeJSON(value) {\n if (typeof value === 'bigint') return value.toString();\n if (Array.isArray(value)) return value.map(sanitizeJSON);\n if (value && typeof value === 'object') {\n // Set -> Array\n if (value instanceof Set) return Array.from(value).map(sanitizeJSON);\n const out = {};\n for (const [k, v] of Object.entries(value)) out[k] = sanitizeJSON(v);\n return out;\n }\n return value;\n}\n\nconst main = async () => {\n await client.connect();\n\n try {\n // Haal een platte lijst met mappen op + status (counts, uidNext, uidValidity, highestModseq, etc.)\n const list = await client.list({\n statusQuery: {\n messages: true,\n recent: true,\n unseen: true,\n uidNext: true,\n uidValidity: true,\n highestModseq: true\n }\n });\n\n // Normaliseer naar JSON-vriendelijke objecten\n const folders = list.map(mb => ({\n path: mb.path,\n name: mb.name,\n delimiter: mb.delimiter,\n parentPath: mb.parentPath,\n flags: Array.from(mb.flags || []), // Set -> Array\n specialUse: mb.specialUse || null,\n listed: !!mb.listed,\n subscribed: !!mb.subscribed,\n status: mb.status || undefined // bevat mogelijk BigInt velden\n }));\n\n // ↳ Converteer BigInt velden naar strings zodat Express/JSON.stringify niet crasht\n msg.payload = sanitizeJSON({ folders });\n node.send(msg);\n } finally {\n await client.logout();\n }\n};\n\nmain().catch(err => {\n node.error(err);\n msg.statusCode = 500;\n msg.payload = { error: err.message };\n node.send(msg);\n});\n\nreturn;\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[{"var":"imapflow","module":"imapflow"}],"x":590,"y":320,"wires":[["29a0e19e85fcee39"]]},{"id":"29a0e19e85fcee39","type":"http response","z":"b287c7e819841bc2","g":"47e1cbd40e16162f","name":"","statusCode":"","headers":{},"x":830,"y":320,"wires":[]},{"id":"e2ef6d44960f40ce","type":"http in","z":"b287c7e819841bc2","g":"47e1cbd40e16162f","name":"","url":"/support/message","method":"post","upload":false,"skipBodyParsing":false,"swaggerDoc":"","x":140,"y":360,"wires":[["0d39276708f25701"]]},{"id":"71e6b532fec66ef9","type":"function","z":"b287c7e819841bc2","g":"47e1cbd40e16162f","name":"send message","func":"const { ImapFlow } = imapflow;\n\nconst IMAP_CONFIG = {\n host: env.get(\"IMAP_HOST\"),\n port: env.get(\"IMAP_PORT\"),\n secure: true,\n auth: {\n user: env.get(\"IMAP_USER\"),\n pass: env.get(\"IMAP_PASS\")\n }\n};\n\nconst SMTP_CONFIG = {\n host: env.get(\"SMTP_HOST\"),\n port: env.get(\"SMTP_PORT\"),\n secure: true,\n auth: {\n user: env.get(\"SMTP_USER\"),\n pass: env.get(\"SMTP_PASS\")\n }\n};\n\n// --- helpers ---------------------------------------------------\nfunction normList(v) {\n if (!v) return undefined;\n if (Array.isArray(v)) return v.filter(Boolean);\n return String(v);\n}\nfunction ensureRe(subject) {\n if (!subject) return '';\n return /^re:/i.test(subject) ? subject : `Re: ${subject}`;\n}\n\nasync function buildReplyHeaders(client, mailbox, uid) {\n let lock = await client.getMailboxLock(mailbox);\n try {\n const msg = await client.fetchOne(uid, { envelope: true, headers: true }, { uid: true });\n if (!msg || !msg.envelope) throw new Error('Original message not found for reply.');\n const env = msg.envelope;\n\n const originalMsgId = env.messageId || null;\n let references = [];\n if (msg.headers) {\n const ref = (msg.headers.get && msg.headers.get('references')) || null;\n if (ref) {\n references = String(ref).trim().split(/\\s+/).filter(Boolean);\n }\n }\n if (originalMsgId) references.push(originalMsgId);\n\n return {\n inReplyTo: originalMsgId || undefined,\n references: references.length ? references : undefined,\n subject: ensureRe(env.subject || ''),\n defaultTo: (env.replyTo && env.replyTo.length ? env.replyTo : env.from) || []\n };\n } finally {\n lock.release();\n }\n}\n\nasync function compileMime(mailOptions) {\n const composer = new MailComposer(mailOptions);\n\n if (typeof composer.compile === 'function') {\n const node = composer.compile();\n return await new Promise((resolve, reject) => {\n node.build((err, buf) => err ? reject(err) : resolve(buf));\n });\n }\n if (typeof composer.build === 'function') {\n return await new Promise((resolve, reject) => {\n composer.build((err, buf) => err ? reject(err) : resolve(buf));\n });\n }\n if (typeof composer.createReadStream === 'function') {\n return await new Promise((resolve, reject) => {\n const chunks = [];\n const s = composer.createReadStream();\n s.on('data', (c) => chunks.push(Buffer.from(c)));\n s.on('end', () => resolve(Buffer.concat(chunks)));\n s.on('error', reject);\n });\n }\n throw new Error('Unsupported MailComposer API: neither compile().build nor build() found.');\n}\n\nconst main = async () => {\n const p = msg.payload || {};\n\n const mode = p.mode || (p.reply ? 'reply' : 'new');\n const saveToSent = !!p.saveToSent;\n const sentFolder = p.sentFolder || 'Sent';\n\n const transporter = nodemailer.createTransport(SMTP_CONFIG);\n\n let replyHeaders = {};\n let replyDefaultTo;\n\n if (mode === 'reply') {\n if (!p.reply || !Number.isFinite(Number(p.reply.uid))) {\n throw new Error('Reply mode requires payload.reply = { mailbox, uid }.');\n }\n const client = new ImapFlow(IMAP_CONFIG);\n await client.connect();\n try {\n const rh = await buildReplyHeaders(\n client,\n String(p.reply.mailbox || 'INBOX'),\n Number(p.reply.uid)\n );\n replyHeaders = { inReplyTo: rh.inReplyTo, references: rh.references, subject: rh.subject };\n replyDefaultTo = rh.defaultTo;\n } finally {\n await client.logout();\n }\n }\n\n const mailOptions = {\n from: p.from || SMTP_CONFIG.auth.user,\n to: normList(p.to) || (mode === 'reply' ? replyDefaultTo : undefined),\n cc: normList(p.cc),\n bcc: normList(p.bcc),\n subject: mode === 'reply' ? (p.subject || replyHeaders.subject || '') : (p.subject || ''),\n text: p.text,\n html: p.html,\n attachments: Array.isArray(p.attachments) ? p.attachments : undefined,\n headers: p.headers || undefined,\n inReplyTo: replyHeaders.inReplyTo || p.inReplyTo,\n references: p.references || replyHeaders.references\n };\n\n if (!mailOptions.to && !mailOptions.cc && !mailOptions.bcc) {\n throw new Error('No recipients: set payload.to / cc / bcc.');\n }\n\n const rawMime = await compileMime(mailOptions);\n\n const info = await transporter.sendMail(mailOptions);\n\n // Reuse one IMAP connection to (a) append to Sent and (b) mark original as \\Answered\n let appendResult = null;\n if (saveToSent || mode === 'reply') {\n const client = new ImapFlow(IMAP_CONFIG);\n await client.connect();\n try {\n if (saveToSent) {\n const raw = await client.append(sentFolder, rawMime, ['\\\\Seen']);\n appendResult = raw\n ? {\n ...raw,\n uidValidity: raw.uidValidity != null ? raw.uidValidity.toString() : undefined\n }\n : null;\n }\n\n // Mark original message as replied: add \\Answered flag using UID\n if (mode === 'reply' && p.reply && Number.isFinite(Number(p.reply.uid))) {\n const originalMailbox = String(p.reply.mailbox || 'INBOX');\n const originalUid = Number(p.reply.uid);\n\n const lock = await client.getMailboxLock(originalMailbox);\n try {\n await client.messageFlagsAdd(originalUid, ['\\\\Answered'], { uid: true });\n } finally {\n lock.release();\n }\n }\n } finally {\n await client.logout();\n }\n }\n\n msg.statusCode = 200;\n msg.payload = {\n ok: true,\n mode,\n smtp: {\n messageId: info.messageId,\n accepted: info.accepted,\n rejected: info.rejected,\n envelope: info.envelope\n },\n savedToSent: !!saveToSent,\n appendResult\n };\n node.send(msg);\n};\n\nreturn main().catch(err => {\n node.error(err);\n msg.statusCode = 500;\n msg.payload = { ok: false, error: err.message };\n node.send(msg);\n});\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[{"var":"imapflow","module":"imapflow"},{"var":"nodemailer","module":"nodemailer"},{"var":"MailComposer","module":"mailcomposer"}],"x":600,"y":360,"wires":[["f59db771e1a48345"]]},{"id":"f59db771e1a48345","type":"http response","z":"b287c7e819841bc2","g":"47e1cbd40e16162f","name":"","statusCode":"","headers":{},"x":830,"y":360,"wires":[]},{"id":"04e3bd8d34c168b9","type":"http in","z":"b287c7e819841bc2","g":"47e1cbd40e16162f","name":"","url":"/support/message/:folder/:uid/read","method":"post","upload":false,"skipBodyParsing":false,"swaggerDoc":"","x":200,"y":160,"wires":[["7aeb084b6caec4e0"]]},{"id":"52b08f36d3cd00a8","type":"function","z":"b287c7e819841bc2","g":"47e1cbd40e16162f","name":"change message read state","func":"const { ImapFlow } = imapflow;\n\nconst client = new ImapFlow({\n host: env.get(\"IMAP_HOST\"),\n port: env.get(\"IMAP_PORT\"),\n secure: true,\n auth: {\n user: env.get(\"IMAP_USER\"),\n pass: env.get(\"IMAP_PASS\")\n }\n});\n\nconst main = async () => {\n await client.connect();\n\n const mailbox = (msg.req.params?.folder || 'INBOX').toString();\n const uid = Number(msg.req.params?.uid ?? msg.req.params?.id);\n if (!Number.isFinite(uid)) {\n msg.statusCode = 400;\n msg.payload = { ok: false, error: 'Missing or invalid uid' };\n node.send(msg);\n await client.logout();\n return;\n }\n\n let lock = await client.getMailboxLock(mailbox);\n try {\n // Ensure the message exists (and avoid returning non-serializable fields)\n const exists = await client.fetchOne(uid, { uid: true }, { uid: true });\n if (!exists) {\n msg.statusCode = 404;\n msg.payload = { ok: false, error: 'Message not found' };\n node.send(msg);\n return;\n }\n\n // Remove \\Seen to mark as UNREAD (use UID mode)\n await client.messageFlagsRemove(uid, ['\\\\Seen'], { uid: true });\n\n // Optionally verify current read state without returning the Set\n let read = undefined;\n try {\n const after = await client.fetchOne(uid, { flags: true }, { uid: true });\n if (after && after.flags && typeof after.flags.has === 'function') {\n read = after.flags.has('\\\\Seen') ? true : false;\n }\n } catch (e) {\n // If verification fails, still return success (read will be undefined)\n node.warn(`Could not verify flags for UID ${uid}: ${e.message}`);\n }\n\n msg.statusCode = 200;\n msg.payload = {\n ok: true,\n mailbox,\n uid,\n read: read === undefined ? false : read // most likely false after removal\n };\n node.send(msg);\n } finally {\n lock.release();\n }\n\n await client.logout();\n};\n\nmain().catch(err => {\n node.error(err);\n msg.statusCode = 500;\n msg.payload = { ok: false, error: err.message };\n node.send(msg);\n});\nreturn;\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[{"var":"imapflow","module":"imapflow"}],"x":640,"y":160,"wires":[["cbba3d707e19b766"]]},{"id":"cbba3d707e19b766","type":"http response","z":"b287c7e819841bc2","g":"47e1cbd40e16162f","name":"","statusCode":"","headers":{},"x":830,"y":160,"wires":[]},{"id":"c4248d9c601bb29b","type":"http in","z":"b287c7e819841bc2","g":"47e1cbd40e16162f","name":"","url":"/support/message/:folder/:uid/delete","method":"post","upload":false,"skipBodyParsing":false,"swaggerDoc":"","x":200,"y":200,"wires":[["654563b586ddbba4"]]},{"id":"5d93f4e8598bf104","type":"function","z":"b287c7e819841bc2","g":"47e1cbd40e16162f","name":"delete message","func":"const { ImapFlow } = imapflow;\n\nconst IMAP_CONFIG = {\n host: env.get(\"IMAP_HOST\"),\n port: env.get(\"IMAP_PORT\"),\n secure: true,\n auth: {\n user: env.get(\"IMAP_USER\"),\n pass: env.get(\"IMAP_PASS\")\n }\n};\n\n// Common Trash names seen across providers (fallback if no special-use flag)\nconst TRASH_NAME_CANDIDATES = [\n 'Trash',\n '[Gmail]/Trash',\n 'Deleted Items',\n 'Deleted Messages',\n 'Bin',\n 'Rubbish',\n 'INBOX.Trash'\n];\n\nfunction eqPath(a, b) {\n if (!a || !b) return false;\n return String(a).toUpperCase() === String(b).toUpperCase();\n}\n\nasync function findOrCreateTrash(client) {\n const boxes = await client.list(); // returns array of mailboxes with .path, .specialUse, etc. :contentReference[oaicite:1]{index=1}\n\n let trash = boxes.find(b => (b.specialUse || '').toLowerCase() === '\\\\trash'); // RFC 6154 special-use :contentReference[oaicite:2]{index=2}\n if (trash) return trash.path;\n\n // Fallback by common names\n trash = boxes.find(b => {\n const path = (b.path || '').toLowerCase();\n const name = (b.name || '').toLowerCase();\n return TRASH_NAME_CANDIDATES.some(c => path === c.toLowerCase() || name === c.toLowerCase());\n });\n if (trash) return trash.path;\n\n // As a last resort, try to create \"Trash\"\n try {\n const created = await client.mailboxCreate('Trash'); // auto-subscribes per docs :contentReference[oaicite:3]{index=3}\n if (created) return 'Trash';\n } catch (e) {\n node.warn(`Could not create Trash folder: ${e.message}`);\n }\n\n return null; // No Trash available\n}\n\nconst main = async () => {\n const client = new ImapFlow(IMAP_CONFIG);\n await client.connect();\n\n const mailbox = (msg.req.params?.folder || 'INBOX').toString();\n const uid = Number(msg.req.params?.uid ?? msg.req.params?.id);\n\n if (!Number.isFinite(uid)) {\n msg.statusCode = 400;\n msg.payload = { ok: false, error: 'Missing or invalid uid' };\n node.send(msg);\n await client.logout();\n return;\n }\n\n const lock = await client.getMailboxLock(mailbox); // safe mailbox access :contentReference[oaicite:4]{index=4}\n try {\n // Ensure the message exists\n const exists = await client.fetchOne(uid, { uid: true }, { uid: true }); // UID mode :contentReference[oaicite:5]{index=5}\n if (!exists) {\n msg.statusCode = 404;\n msg.payload = { ok: false, error: 'Message not found' };\n node.send(msg);\n return;\n }\n\n // Resolve Trash path\n const trashPath = await findOrCreateTrash(client);\n\n if (!trashPath) {\n // No Trash available anywhere — do a hard delete\n await client.messageDelete(uid, { uid: true }); // permanently delete in current mailbox :contentReference[oaicite:6]{index=6}\n msg.statusCode = 200;\n msg.payload = { ok: true, action: 'deleted', mailbox, uid, permanently: true, reason: 'no-trash-available' };\n node.send(msg);\n return;\n }\n\n if (eqPath(mailbox, trashPath)) {\n // Already in Trash -> permanently delete\n await client.messageDelete(uid, { uid: true }); // :contentReference[oaicite:7]{index=7}\n msg.statusCode = 200;\n msg.payload = { ok: true, action: 'deleted', mailbox, uid, permanently: true };\n node.send(msg);\n return;\n }\n\n // Not in Trash -> move to Trash\n await client.messageMove(uid, trashPath, { uid: true }); // move by UID :contentReference[oaicite:8]{index=8}\n msg.statusCode = 200;\n msg.payload = { ok: true, action: 'moved-to-trash', from: mailbox, to: trashPath, uid };\n node.send(msg);\n } finally {\n lock.release();\n await client.logout();\n }\n};\n\nmain().catch(err => {\n node.error(err);\n msg.statusCode = 500;\n msg.payload = { ok: false, error: err.message };\n node.send(msg);\n});\nreturn;\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[{"var":"imapflow","module":"imapflow"}],"x":600,"y":200,"wires":[["ed8f6176129b3bc7"]]},{"id":"ed8f6176129b3bc7","type":"http response","z":"b287c7e819841bc2","g":"47e1cbd40e16162f","name":"","statusCode":"","headers":{},"x":830,"y":200,"wires":[]},{"id":"0d438483036a051e","type":"http in","z":"b287c7e819841bc2","g":"47e1cbd40e16162f","name":"","url":"/support/message/:folder/:uid/move","method":"post","upload":false,"skipBodyParsing":false,"swaggerDoc":"","x":200,"y":240,"wires":[["a3c55706d4733d68"]]},{"id":"8e5b167df721627c","type":"function","z":"b287c7e819841bc2","g":"47e1cbd40e16162f","name":"move message","func":"const { ImapFlow } = imapflow;\n\nconst IMAP_CONFIG = {\n host: env.get(\"IMAP_HOST\"),\n port: env.get(\"IMAP_PORT\"),\n secure: true,\n auth: {\n user: env.get(\"IMAP_USER\"),\n pass: env.get(\"IMAP_PASS\")\n }\n};\n\n// make UIDPLUS results JSON-safe\nfunction sanitizeMoveResult(res) {\n if (!res) return null;\n let uidMapObj = undefined;\n if (res.uidMap && typeof res.uidMap.entries === 'function') {\n uidMapObj = {};\n for (const [k, v] of res.uidMap.entries()) {\n uidMapObj[String(k)] = v;\n }\n }\n return {\n path: res.path,\n destination: res.destination,\n uidValidity: res.uidValidity != null ? String(res.uidValidity) : undefined,\n uidMap: uidMapObj\n };\n}\n\nconst main = async () => {\n const client = new ImapFlow(IMAP_CONFIG);\n await client.connect();\n\n try {\n const mailbox = (msg.req.params?.folder || 'INBOX').toString();\n const uid = Number(msg.req.params?.uid ?? msg.req.params?.id);\n const target = (msg.payload?.target || '').toString().trim();\n\n if (!Number.isFinite(uid)) {\n msg.statusCode = 400;\n msg.payload = { ok: false, error: 'Missing or invalid uid' };\n node.send(msg);\n return;\n }\n if (!target) {\n msg.statusCode = 400;\n msg.payload = { ok: false, error: 'Missing target folder' };\n node.send(msg);\n return;\n }\n\n if (target === mailbox) {\n msg.statusCode = 200;\n msg.payload = { ok: true, moved: false, reason: 'source and target are the same', from: mailbox, to: target, uid };\n node.send(msg);\n return;\n }\n\n // Ensure source mailbox is selected & locked\n let lock = await client.getMailboxLock(mailbox);\n try {\n // Verify message exists (by UID)\n const exists = await client.fetchOne(uid, { uid: true }, { uid: true });\n if (!exists) {\n msg.statusCode = 404;\n msg.payload = { ok: false, error: 'Message not found', from: mailbox, uid };\n node.send(msg);\n return;\n }\n\n // Make sure destination folder exists (create if missing)\n try {\n await client.mailboxCreate(target);\n } catch (e) {\n // if it already exists, mailboxCreate returns {created:false} or may throw TRYCREATE-related errors — ignore benign failures\n node.debug && node.debug(`mailboxCreate(${target}) -> ${e.message}`);\n }\n\n // Move by UID\n let moveRes = await client.messageMove(uid, target, { uid: true });\n\n // Build response\n const safe = sanitizeMoveResult(moveRes) || {};\n let newUid = undefined;\n if (safe.uidMap && Object.prototype.hasOwnProperty.call(safe.uidMap, String(uid))) {\n newUid = safe.uidMap[String(uid)];\n }\n\n msg.statusCode = 200;\n msg.payload = {\n ok: true,\n moved: true,\n from: mailbox,\n to: target,\n uid,\n newUid,\n result: safe\n };\n node.send(msg);\n } finally {\n lock.release();\n }\n } catch (err) {\n node.error(err);\n msg.statusCode = 500;\n msg.payload = { ok: false, error: err.message };\n node.send(msg);\n } finally {\n await client.logout();\n }\n};\n\nreturn main();\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[{"var":"imapflow","module":"imapflow"}],"x":600,"y":240,"wires":[["bd614f296b698e5e"]]},{"id":"bd614f296b698e5e","type":"http response","z":"b287c7e819841bc2","g":"47e1cbd40e16162f","name":"","statusCode":"","headers":{},"x":830,"y":240,"wires":[]},{"id":"cdd09782ef8a3d07","type":"subflow:488dae2d710e1383","z":"b287c7e819841bc2","g":"47e1cbd40e16162f","name":"","env":[{"name":"USERNAME","value":"USER","type":"env"},{"name":"PASSWORD","value":"PASS","type":"env"}],"x":440,"y":60,"wires":[["1d53d6fb55ce4ed5"]]},{"id":"d6f955a7e39bc94b","type":"subflow:488dae2d710e1383","z":"b287c7e819841bc2","g":"47e1cbd40e16162f","name":"","env":[{"name":"USERNAME","value":"USER","type":"env"},{"name":"PASSWORD","value":"PASS","type":"env"}],"x":440,"y":100,"wires":[["3fd8cf4f2a9dc40a"]]},{"id":"7aeb084b6caec4e0","type":"subflow:488dae2d710e1383","z":"b287c7e819841bc2","g":"47e1cbd40e16162f","name":"","env":[{"name":"USERNAME","value":"USER","type":"env"},{"name":"PASSWORD","value":"PASS","type":"env"}],"x":440,"y":160,"wires":[["52b08f36d3cd00a8"]]},{"id":"654563b586ddbba4","type":"subflow:488dae2d710e1383","z":"b287c7e819841bc2","g":"47e1cbd40e16162f","name":"","env":[{"name":"USERNAME","value":"USER","type":"env"},{"name":"PASSWORD","value":"PASS","type":"env"}],"x":440,"y":200,"wires":[["5d93f4e8598bf104"]]},{"id":"a3c55706d4733d68","type":"subflow:488dae2d710e1383","z":"b287c7e819841bc2","g":"47e1cbd40e16162f","name":"","env":[{"name":"USERNAME","value":"USER","type":"env"},{"name":"PASSWORD","value":"PASS","type":"env"}],"x":440,"y":240,"wires":[["8e5b167df721627c"]]},{"id":"7b1a659a60dd2400","type":"subflow:488dae2d710e1383","z":"b287c7e819841bc2","g":"47e1cbd40e16162f","name":"","env":[{"name":"USERNAME","value":"USER","type":"env"},{"name":"PASSWORD","value":"PASS","type":"env"}],"x":440,"y":320,"wires":[["b62da3ad4b9dab9f"]]},{"id":"0d39276708f25701","type":"subflow:488dae2d710e1383","z":"b287c7e819841bc2","g":"47e1cbd40e16162f","name":"","env":[{"name":"USERNAME","value":"USER","type":"env"},{"name":"PASSWORD","value":"PASS","type":"env"}],"x":440,"y":360,"wires":[["71e6b532fec66ef9"]]},{"id":"3f09cafd3da91d13","type":"subflow:488dae2d710e1383","z":"b287c7e819841bc2","g":"47e1cbd40e16162f","name":"","env":[{"name":"USERNAME","value":"USER","type":"env"},{"name":"PASSWORD","value":"PASS","type":"env"}],"x":440,"y":400,"wires":[["a733f5ed1f29d92f"]]}]