diff --git a/esbuild.config.mjs b/esbuild.config.mjs
index 77ebe93..3262a54 100644
--- a/esbuild.config.mjs
+++ b/esbuild.config.mjs
@@ -14,6 +14,10 @@ const ctx = await esbuild.context(
bundle: true,
minify: doMinify,
sourcemap: false,
+ define: {
+ '__VUE_OPTIONS_API__': 'true',
+ '__VUE_PROD_DEVTOOLS__': 'false',
+ },
outdir: "server/ui/dist/",
plugins: [pluginVue(), sassPlugin()],
loader: {
diff --git a/server/apiv1/api.go b/server/apiv1/api.go
index 0dfbed7..ed1128f 100644
--- a/server/apiv1/api.go
+++ b/server/apiv1/api.go
@@ -304,7 +304,7 @@ func DownloadRaw(w http.ResponseWriter, r *http.Request) {
return
}
- w.Header().Set("Content-Type", "text/plain")
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
if dl == "1" {
w.Header().Set("Content-Disposition", "attachment; filename=\""+id+".eml\"")
}
@@ -495,7 +495,7 @@ func SetTags(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("ok"))
}
-// ReleaseMessage (method: POST) will release a message via a preconfigured external SMTP server.
+// ReleaseMessage (method: POST) will release a message via a pre-configured external SMTP server.
// If no IDs are provided then all messages are updated.
func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
// swagger:route POST /api/v1/message/{ID}/release message Release
diff --git a/server/ui-src/App.vue b/server/ui-src/App.vue
index 68a270a..2f720bd 100644
--- a/server/ui-src/App.vue
+++ b/server/ui-src/App.vue
@@ -4,6 +4,7 @@ import Message from './templates/Message.vue'
import MessageSummary from './templates/MessageSummary.vue'
import MessageRelease from './templates/MessageRelease.vue'
import MessageToast from './templates/MessageToast.vue'
+import ThemeToggle from './templates/ThemeToggle.vue'
import moment from 'moment'
import Tinycon from 'tinycon'
@@ -14,7 +15,8 @@ export default {
Message,
MessageSummary,
MessageRelease,
- MessageToast
+ MessageToast,
+ ThemeToggle,
},
data() {
@@ -50,55 +52,55 @@ export default {
watch: {
currentPath(v, old) {
if (v && v.match(/^[a-z0-9]+-[a-z0-9]+-[a-z0-9]+-[a-z0-9]+-[a-z0-9]+$/)) {
- this.openMessage();
+ this.openMessage()
} else {
- this.message = false;
+ this.message = false
}
},
unread(v, old) {
if (v == this.tcStatus) {
- return;
+ return
}
- this.tcStatus = v;
+ this.tcStatus = v
if (v == 0) {
- Tinycon.reset();
+ Tinycon.reset()
} else {
- Tinycon.setBubble(v);
+ Tinycon.setBubble(v)
}
}
},
computed: {
canPrev: function () {
- return this.start > 0;
+ return this.start > 0
},
canNext: function () {
- return this.total > (this.start + this.count);
+ return this.total > (this.start + this.count)
},
unreadInSearch: function () {
if (!this.searching) {
- return false;
+ return false
}
- return this.items.filter(i => !i.Read).length;
+ return this.items.filter(i => !i.Read).length
}
},
mounted() {
- this.currentPath = window.location.hash.slice(1);
+ this.currentPath = window.location.hash.slice(1)
window.addEventListener('hashchange', () => {
- this.currentPath = window.location.hash.slice(1);
- });
+ this.currentPath = window.location.hash.slice(1)
+ })
this.notificationsSupported = window.isSecureContext
- && ("Notification" in window && Notification.permission !== "denied");
- this.notificationsEnabled = this.notificationsSupported && Notification.permission == "granted";
+ && ("Notification" in window && Notification.permission !== "denied")
+ this.notificationsEnabled = this.notificationsSupported && Notification.permission == "granted"
Tinycon.setOptions({
height: 11,
background: '#dd0000',
fallback: false
- });
+ })
moment.updateLocale('en', {
relativeTime: {
@@ -119,11 +121,11 @@ export default {
y: "a year",
yy: "%d years"
}
- });
+ })
- this.connect();
- this.getUISettings();
- this.loadMessages();
+ this.connect()
+ this.getUISettings()
+ this.loadMessages()
},
methods: {
@@ -131,143 +133,143 @@ export default {
let now = Date.now()
// prevent double loading when UI loads & websocket connects
if (this.lastLoaded && now - this.lastLoaded < 250) {
- return;
+ return
}
if (this.start == 0) {
- this.lastLoaded = now;
+ this.lastLoaded = now
}
- let self = this;
- let params = {};
- self.selected = [];
+ let self = this
+ let params = {}
+ self.selected = []
- let uri = 'api/v1/messages';
+ let uri = 'api/v1/messages'
if (self.search) {
- self.searching = true;
- self.items = [];
+ self.searching = true
+ self.items = []
uri = 'api/v1/search'
- self.start = 0; // search is displayed on one page
- params['query'] = self.search;
- params['limit'] = 200;
+ self.start = 0 // search is displayed on one page
+ params['query'] = self.search
+ params['limit'] = 200
} else {
- self.searching = false;
- params['limit'] = self.limit;
+ self.searching = false
+ params['limit'] = self.limit
if (self.start > 0) {
- params['start'] = self.start;
+ params['start'] = self.start
}
}
self.get(uri, params, function (response) {
- self.total = response.data.total;
- self.unread = response.data.unread;
- self.count = response.data.count;
- self.start = response.data.start;
- self.items = response.data.messages;
- self.tags = response.data.tags;
- self.existingTags = JSON.parse(JSON.stringify(self.tags));
+ self.total = response.data.total
+ self.unread = response.data.unread
+ self.count = response.data.count
+ self.start = response.data.start
+ self.items = response.data.messages
+ self.tags = response.data.tags
+ self.existingTags = JSON.parse(JSON.stringify(self.tags))
// if pagination > 0 && results == 0 reload first page (prune)
if (response.data.count == 0 && response.data.start > 0) {
- self.start = 0;
- return self.loadMessages();
+ self.start = 0
+ return self.loadMessages()
}
if (!self.scrollInPlace) {
- let mp = document.getElementById('message-page');
+ let mp = document.getElementById('message-page')
if (mp) {
- mp.scrollTop = 0;
+ mp.scrollTop = 0
}
}
- self.scrollInPlace = false;
- });
+ self.scrollInPlace = false
+ })
},
getUISettings: function () {
- let self = this;
+ let self = this
self.get('api/v1/webui', null, function (response) {
- self.relayConfig = response.data;
- });
+ self.relayConfig = response.data
+ })
},
doSearch: function (e) {
- e.preventDefault();
- this.loadMessages();
+ e.preventDefault()
+ this.loadMessages()
},
tagSearch: function (e, tag) {
- e.preventDefault();
+ e.preventDefault()
if (tag.match(/ /)) {
- tag = '"' + tag + '"';
+ tag = '"' + tag + '"'
}
- this.search = 'tag:' + tag;
- window.location.hash = "";
- this.loadMessages();
+ this.search = 'tag:' + tag
+ window.location.hash = ""
+ this.loadMessages()
},
resetSearch: function (e) {
- e.preventDefault();
- this.search = '';
- this.scrollInPlace = true;
- this.loadMessages();
+ e.preventDefault()
+ this.search = ''
+ this.scrollInPlace = true
+ this.loadMessages()
},
reloadMessages: function () {
- this.search = "";
- this.start = 0;
- this.loadMessages();
+ this.search = ""
+ this.start = 0
+ this.loadMessages()
},
viewNext: function () {
- this.start = parseInt(this.start, 10) + parseInt(this.limit, 10);
- this.loadMessages();
+ this.start = parseInt(this.start, 10) + parseInt(this.limit, 10)
+ this.loadMessages()
},
viewPrev: function () {
- let s = this.start - this.limit;
+ let s = this.start - this.limit
if (s < 0) {
- s = 0;
+ s = 0
}
- this.start = s;
- this.loadMessages();
+ this.start = s
+ this.loadMessages()
},
openMessage: function (id) {
- let self = this;
- self.selected = [];
- self.releaseAddresses = false;
- self.toastMessage = false;
- self.existingTags = JSON.parse(JSON.stringify(self.tags));
+ let self = this
+ self.selected = []
+ self.releaseAddresses = false
+ self.toastMessage = false
+ self.existingTags = JSON.parse(JSON.stringify(self.tags))
let uri = 'api/v1/message/' + self.currentPath
self.get(uri, false, function (response) {
for (let i in self.items) {
if (self.items[i].ID == self.currentPath) {
if (!self.items[i].Read) {
- self.items[i].Read = true;
- self.unread--;
+ self.items[i].Read = true
+ self.unread--
}
}
}
- let d = response.data;
+ let d = response.data
// replace inline images embedded as inline attachments
if (d.HTML && d.Inline) {
for (let i in d.Inline) {
- let a = d.Inline[i];
+ let a = d.Inline[i]
if (a.ContentID != '') {
d.HTML = d.HTML.replace(
new RegExp('(=["\']?)(cid:' + a.ContentID + ')(["|\'|\\s|\\/|>|;])', 'g'),
'$1' + window.location.origin + window.location.pathname + 'api/v1/message/' + d.ID + '/part/' + a.PartID + '$3'
- );
+ )
}
if (a.FileName.match(/^[a-zA-Z0-9\_\-\.]+$/)) {
// some old email clients use the filename
d.HTML = d.HTML.replace(
new RegExp('(=["\']?)(' + a.FileName + ')(["|\'|\\s|\\/|>|;])', 'g'),
'$1' + window.location.origin + window.location.pathname + 'api/v1/message/' + d.ID + '/part/' + a.PartID + '$3'
- );
+ )
}
}
}
@@ -275,381 +277,381 @@ export default {
// replace inline images embedded as regular attachments
if (d.HTML && d.Attachments) {
for (let i in d.Attachments) {
- let a = d.Attachments[i];
+ let a = d.Attachments[i]
if (a.ContentID != '') {
d.HTML = d.HTML.replace(
new RegExp('(=["\']?)(cid:' + a.ContentID + ')(["|\'|\\s|\\/|>|;])', 'g'),
'$1' + window.location.origin + window.location.pathname + 'api/v1/message/' + d.ID + '/part/' + a.PartID + '$3'
- );
+ )
}
if (a.FileName.match(/^[a-zA-Z0-9\_\-\.]+$/)) {
// some old email clients use the filename
d.HTML = d.HTML.replace(
new RegExp('(=["\']?)(' + a.FileName + ')(["|\'|\\s|\\/|>|;])', 'g'),
'$1' + window.location.origin + window.location.pathname + 'api/v1/message/' + d.ID + '/part/' + a.PartID + '$3'
- );
+ )
}
}
}
- self.message = d;
+ self.message = d
// generate the prev/next links based on current message list
- self.messagePrev = false;
- self.messageNext = false;
- let found = false;
+ self.messagePrev = false
+ self.messageNext = false
+ let found = false
for (let i in self.items) {
if (self.items[i].ID == self.message.ID) {
- found = true;
+ found = true
} else if (found && !self.messageNext) {
- self.messageNext = self.items[i].ID;
- break;
+ self.messageNext = self.items[i].ID
+ break
} else {
- self.messagePrev = self.items[i].ID;
+ self.messagePrev = self.items[i].ID
}
}
- });
+ })
},
// universal handler to delete current or selected messages
deleteMessages: function () {
- let ids = [];
- let self = this;
+ let ids = []
+ let self = this
if (self.message) {
- ids.push(self.message.ID);
+ ids.push(self.message.ID)
} else {
- ids = JSON.parse(JSON.stringify(self.selected));
+ ids = JSON.parse(JSON.stringify(self.selected))
}
if (!ids.length) {
- return false;
+ return false
}
- let uri = 'api/v1/messages';
+ let uri = 'api/v1/messages'
self.delete(uri, { 'ids': ids }, function (response) {
- window.location.hash = "";
- self.scrollInPlace = true;
- self.loadMessages();
- });
+ window.location.hash = ""
+ self.scrollInPlace = true
+ self.loadMessages()
+ })
},
// delete messages displayed in current search
deleteSearch: function () {
- let ids = this.items.map(item => item.ID);
+ let ids = this.items.map(item => item.ID)
if (!ids.length) {
- return false;
+ return false
}
- let self = this;
- let uri = 'api/v1/messages';
+ let self = this
+ let uri = 'api/v1/messages'
self.delete(uri, { 'ids': ids }, function (response) {
- window.location.hash = "";
- self.scrollInPlace = true;
- self.loadMessages();
- });
+ window.location.hash = ""
+ self.scrollInPlace = true
+ self.loadMessages()
+ })
},
// delete all messages from mailbox
deleteAll: function () {
- let self = this;
- let uri = 'api/v1/messages';
+ let self = this
+ let uri = 'api/v1/messages'
self.delete(uri, false, function (response) {
- window.location.hash = "";
- self.reloadMessages();
- });
+ window.location.hash = ""
+ self.reloadMessages()
+ })
},
// mark current message as read
markUnread: function () {
- let self = this;
+ let self = this
if (!self.message) {
- return false;
+ return false
}
- let uri = 'api/v1/messages';
+ let uri = 'api/v1/messages'
self.put(uri, { 'read': false, 'ids': [self.message.ID] }, function (response) {
- window.location.hash = "";
- self.scrollInPlace = true;
- self.loadMessages();
- });
+ window.location.hash = ""
+ self.scrollInPlace = true
+ self.loadMessages()
+ })
},
// mark all messages in mailbox as read
markAllRead: function () {
- let self = this;
+ let self = this
let uri = 'api/v1/messages'
self.put(uri, { 'read': true }, function (response) {
- window.location.hash = "";
- self.scrollInPlace = true;
- self.loadMessages();
- });
+ window.location.hash = ""
+ self.scrollInPlace = true
+ self.loadMessages()
+ })
},
// mark messages in current search as read
markSearchRead: function () {
- let ids = this.items.map(item => item.ID);
+ let ids = this.items.map(item => item.ID)
if (!ids.length) {
- return false;
+ return false
}
- let self = this;
- let uri = 'api/v1/messages';
+ let self = this
+ let uri = 'api/v1/messages'
self.put(uri, { 'read': true, 'ids': ids }, function (response) {
- window.location.hash = "";
- self.scrollInPlace = true;
- self.loadMessages();
- });
+ window.location.hash = ""
+ self.scrollInPlace = true
+ self.loadMessages()
+ })
},
// mark selected messages as read
markSelectedRead: function () {
- let self = this;
+ let self = this
if (!self.selected.length) {
- return false;
+ return false
}
- let uri = 'api/v1/messages';
+ let uri = 'api/v1/messages'
self.put(uri, { 'read': true, 'ids': self.selected }, function (response) {
- window.location.hash = "";
- self.scrollInPlace = true;
- self.loadMessages();
- });
+ window.location.hash = ""
+ self.scrollInPlace = true
+ self.loadMessages()
+ })
},
// mark selected messages as unread
markSelectedUnread: function () {
- let self = this;
+ let self = this
if (!self.selected.length) {
- return false;
+ return false
}
- let uri = 'api/v1/messages';
+ let uri = 'api/v1/messages'
self.put(uri, { 'read': false, 'ids': self.selected }, function (response) {
- window.location.hash = "";
- self.scrollInPlace = true;
- self.loadMessages();
- });
+ window.location.hash = ""
+ self.scrollInPlace = true
+ self.loadMessages()
+ })
},
// test if any selected emails are unread
selectedHasUnread: function () {
if (!this.selected.length) {
- return false;
+ return false
}
for (let i in this.items) {
if (this.isSelected(this.items[i].ID) && !this.items[i].Read) {
- return true;
+ return true
}
}
- return false;
+ return false
},
// test of any selected emails are read
selectedHasRead: function () {
if (!this.selected.length) {
- return false;
+ return false
}
for (let i in this.items) {
if (this.isSelected(this.items[i].ID) && this.items[i].Read) {
- return true;
+ return true
}
}
- return false;
+ return false
},
// websocket connect
connect: function () {
- let wsproto = location.protocol == 'https:' ? 'wss' : 'ws';
+ let wsproto = location.protocol == 'https:' ? 'wss' : 'ws'
let ws = new WebSocket(
wsproto + "://" + document.location.host + document.location.pathname + "api/events"
- );
- let self = this;
+ )
+ let self = this
ws.onmessage = function (e) {
- let response = JSON.parse(e.data);
+ let response = JSON.parse(e.data)
if (!response) {
- return;
+ return
}
// new messages
if (response.Type == "new" && response.Data) {
if (!self.searching) {
if (self.start < 1) {
// first page
- self.items.unshift(response.Data);
+ self.items.unshift(response.Data)
if (self.items.length > self.limit) {
- self.items.pop();
+ self.items.pop()
}
// first message was open, set messagePrev
if (!self.messagePrev) {
- self.messagePrev = response.Data.ID;
+ self.messagePrev = response.Data.ID
}
} else {
- self.start++;
+ self.start++
}
}
- self.total++;
- self.unread++;
+ self.total++
+ self.unread++
for (let i in response.Data.Tags) {
if (self.tags.indexOf(response.Data.Tags[i]) < 0) {
- self.tags.push(response.Data.Tags[i]);
- self.tags.sort();
+ self.tags.push(response.Data.Tags[i])
+ self.tags.sort()
}
}
- let from = response.Data.From != null ? response.Data.From.Address : '[unknown]';
- self.browserNotify("New mail from: " + from, response.Data.Subject);
- self.setMessageToast(response.Data);
+ let from = response.Data.From != null ? response.Data.From.Address : '[unknown]'
+ self.browserNotify("New mail from: " + from, response.Data.Subject)
+ self.setMessageToast(response.Data)
} else if (response.Type == "prune") {
// messages have been deleted, reload messages to adjust
- self.scrollInPlace = true;
- self.loadMessages();
+ self.scrollInPlace = true
+ self.loadMessages()
}
}
ws.onopen = function () {
- self.isConnected = true;
- self.loadMessages();
+ self.isConnected = true
+ self.loadMessages()
}
ws.onclose = function (e) {
- self.isConnected = false;
+ self.isConnected = false
setTimeout(function () {
- self.connect(); // reconnect
- }, 1000);
+ self.connect() // reconnect
+ }, 1000)
}
ws.onerror = function (err) {
- ws.close();
+ ws.close()
}
},
getPrimaryEmailTo: function (message) {
for (let i in message.To) {
- return message.To[i].Address;
+ return message.To[i].Address
}
- return '[ Undisclosed recipients ]';
+ return '[ Undisclosed recipients ]'
},
getRelativeCreated: function (message) {
let d = new Date(message.Created)
- return moment(d).fromNow().toString();
+ return moment(d).fromNow().toString()
},
browserNotify: function (title, message) {
if (!("Notification" in window)) {
- return;
+ return
}
if (Notification.permission === "granted") {
- let b = message.Subject;
+ let b = message.Subject
let options = {
body: message,
icon: 'notification.png'
}
- new Notification(title, options);
+ new Notification(title, options)
}
},
requestNotifications: function () {
// check if the browser supports notifications
if (!("Notification" in window)) {
- alert("This browser does not support desktop notification");
+ alert("This browser does not support desktop notification")
}
// we need to ask the user for permission
else if (Notification.permission !== "denied") {
- let self = this;
+ let self = this
Notification.requestPermission().then(function (permission) {
// if the user accepts, let's create a notification
if (permission === "granted") {
- self.browserNotify("Notifications enabled", "You will receive notifications when new mails are received.");
- self.notificationsEnabled = true;
+ self.browserNotify("Notifications enabled", "You will receive notifications when new mails are received.")
+ self.notificationsEnabled = true
}
- });
+ })
}
},
toggleSelected: function (e, id) {
- e.preventDefault();
+ e.preventDefault()
if (this.isSelected(id)) {
this.selected = this.selected.filter(function (ele) {
- return ele != id;
- });
+ return ele != id
+ })
} else {
- this.selected.push(id);
+ this.selected.push(id)
}
},
selectRange: function (e, id) {
- e.preventDefault();
+ e.preventDefault()
- let selecting = false;
- let lastSelected = this.selected.length > 0 && this.selected[this.selected.length - 1];
+ let selecting = false
+ let lastSelected = this.selected.length > 0 && this.selected[this.selected.length - 1]
if (lastSelected == id) {
this.selected = this.selected.filter(function (ele) {
- return ele != id;
- });
- return;
+ return ele != id
+ })
+ return
}
if (lastSelected === false) {
- this.selected.push(id);
- return;
+ this.selected.push(id)
+ return
}
for (let d of this.items) {
if (selecting) {
if (!this.isSelected(d.ID)) {
- this.selected.push(d.ID);
+ this.selected.push(d.ID)
}
if (d.ID == lastSelected || d.ID == id) {
// reached backwards select
- break;
+ break
}
} else if (d.ID == id || d.ID == lastSelected) {
if (!this.isSelected(d.ID)) {
- this.selected.push(d.ID);
+ this.selected.push(d.ID)
}
- selecting = true;
+ selecting = true
}
}
},
isSelected: function (id) {
- return this.selected.indexOf(id) != -1;
+ return this.selected.indexOf(id) != -1
},
inSearch: function (tag) {
- tag = tag.toLowerCase();
+ tag = tag.toLowerCase()
if (tag.match(/ /)) {
- tag = '"' + tag + '"';
+ tag = '"' + tag + '"'
}
- return this.search.toLowerCase().indexOf('tag:' + tag) > -1;
+ return this.search.toLowerCase().indexOf('tag:' + tag) > -1
},
loadInfo: function (e) {
- e.preventDefault();
- let self = this;
+ e.preventDefault()
+ let self = this
self.get('api/v1/info', false, function (response) {
- self.appInfo = response.data;
- self.modal('AppInfoModal').show();
- });
+ self.appInfo = response.data
+ self.modal('AppInfoModal').show()
+ })
},
downloadMessageBody: function (str, ext) {
- let dl = document.createElement('a');
- dl.href = "data:text/plain," + encodeURIComponent(str);
- dl.target = '_blank';
- dl.download = this.message.ID + '.' + ext;
- dl.click();
+ let dl = document.createElement('a')
+ dl.href = "data:text/plain," + encodeURIComponent(str)
+ dl.target = '_blank'
+ dl.download = this.message.ID + '.' + ext
+ dl.click()
},
initReleaseModal: function () {
- this.releaseAddresses = false;
- let addresses = [];
+ this.releaseAddresses = false
+ let addresses = []
for (let i in this.message.To) {
addresses.push(this.message.To[i].Address)
}
@@ -661,31 +663,31 @@ export default {
}
// include only unique email addresses, regardless of casing
- let uAddresses = new Map(addresses.map(a => [a.toLowerCase(), a]));
- this.releaseAddresses = [...uAddresses.values()];
+ let uAddresses = new Map(addresses.map(a => [a.toLowerCase(), a]))
+ this.releaseAddresses = [...uAddresses.values()]
- let self = this;
+ let self = this
window.setTimeout(function () {
// delay to allow elements to load
- self.modal('ReleaseModal').show();
+ self.modal('ReleaseModal').show()
window.setTimeout(function () {
document.querySelector('#ReleaseModal input[role="combobox"]').focus()
- }, 500);
- }, 300);
+ }, 500)
+ }, 300)
},
setMessageToast: function (m) {
// don't display if browser notifications are enabled, or a toast is already displayed
if (this.notificationsEnabled || this.toastMessage) {
- return;
+ return
}
- this.toastMessage = m;
+ this.toastMessage = m
},
clearMessageToast: function () {
- this.toastMessage = false;
+ this.toastMessage = false
}
}
}
@@ -775,13 +777,13 @@ export default {
Mailpit
-