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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');\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 ? `&lt;${this.escapeHtml(from.address)}&gt;` : '';\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"]]}]

Flow Info

Created 7 months ago
Rating: not yet rated

Actions

Rate:

Node Types

Core
  • function (x8)
  • http in (x8)
  • http response (x9)
  • template (x1)
Other
  • group (x1)
  • subflow (x1)
  • subflow:488dae2d710e1383 (x8)
  • tab (x1)

Tags

  • imap
  • email
  • webclient
Copy this flow JSON to your clipboard and then import into Node-RED using the Import From > Clipboard (Ctrl-I) menu option