diff --git a/go.mod b/go.mod index db3904a..7899ebb 100644 --- a/go.mod +++ b/go.mod @@ -27,6 +27,7 @@ require ( github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962 // indirect github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect github.com/cznic/ql v1.2.0 // indirect + github.com/disintegration/imaging v1.6.2 // indirect github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect github.com/google/go-cmp v0.5.8 // indirect github.com/google/uuid v1.3.0 // indirect @@ -44,6 +45,7 @@ require ( github.com/stretchr/testify v1.7.2 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d // indirect + golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b // indirect golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64 // indirect diff --git a/go.sum b/go.sum index 6841c31..4710a99 100644 --- a/go.sum +++ b/go.sum @@ -33,6 +33,8 @@ github.com/cznic/zappy v0.0.0-20160723133515-2533cb5b45cc/go.mod h1:Y1SNZ4dRUOKX github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= +github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712 h1:aaQcKT9WumO6JEJcRyTqFVq4XUZiUcKR2/GI31TOcz8= @@ -136,6 +138,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d h1:3qF+Z8Hkrw9sOhrFHti9TlB1Hkac1x+DNRkv0XQiFjo= golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 h1:6zppjxzCulZykYSLyVDYbneBfbaBIQPYMevg0bEwv2s= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= diff --git a/server/api.go b/server/api.go index 70a8d16..09717a4 100644 --- a/server/api.go +++ b/server/api.go @@ -90,7 +90,7 @@ func apiOpenMessage(w http.ResponseWriter, r *http.Request) { msg, err := storage.GetMessage(id) if err != nil { - httpError(w, err.Error()) + httpError(w, "Message not found") return } diff --git a/server/server.go b/server/server.go index 9957cda..6a1d428 100644 --- a/server/server.go +++ b/server/server.go @@ -45,6 +45,7 @@ func Listen() { r.HandleFunc("/api/unread", apiMarkSelectedUnread).Methods("POST") r.HandleFunc("/api/{id}/raw", middleWareFunc(apiDownloadRaw)).Methods("GET") r.HandleFunc("/api/{id}/part/{partID}", middleWareFunc(apiDownloadAttachment)).Methods("GET") + r.HandleFunc("/api/{id}/part/{partID}/thumb", middleWareFunc(apiAttachmentThumbnail)).Methods("GET") r.HandleFunc("/api/{id}/delete", middleWareFunc(apiDeleteOne)).Methods("GET") r.HandleFunc("/api/{id}/unread", middleWareFunc(apiUnreadOne)).Methods("GET") r.HandleFunc("/api/{id}", middleWareFunc(apiOpenMessage)).Methods("GET") diff --git a/server/thumbnails.go b/server/thumbnails.go new file mode 100644 index 0000000..741f366 --- /dev/null +++ b/server/thumbnails.go @@ -0,0 +1,107 @@ +package server + +import ( + "bufio" + "bytes" + "image" + "image/color" + "image/draw" + "image/jpeg" + "net/http" + "strings" + + "github.com/axllent/mailpit/logger" + "github.com/axllent/mailpit/storage" + "github.com/disintegration/imaging" + "github.com/gorilla/mux" + "github.com/jhillyerd/enmime" +) + +var ( + thumbWidth = 180 + thumbHeight = 120 +) + +// Attachment thumbnail (images only) +func apiAttachmentThumbnail(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + + id := vars["id"] + partID := vars["partID"] + + a, err := storage.GetAttachmentPart(id, partID) + if err != nil { + httpError(w, err.Error()) + return + } + + fileName := a.FileName + if fileName == "" { + fileName = a.ContentID + } + + if !strings.HasPrefix(a.ContentType, "image/") { + blankImage(a, w) + return + } + + buf := bytes.NewBuffer(a.Content) + + img, err := imaging.Decode(buf) + if err != nil { + // it's not an image, return default + logger.Log().Warning(err) + blankImage(a, w) + return + } + + var b bytes.Buffer + foo := bufio.NewWriter(&b) + + var dstImageFill *image.NRGBA + + if img.Bounds().Dx() < thumbWidth || img.Bounds().Dy() < thumbHeight { + dstImageFill = imaging.Fit(img, thumbWidth, thumbHeight, imaging.Lanczos) + } else { + dstImageFill = imaging.Fill(img, thumbWidth, thumbHeight, imaging.Center, imaging.Lanczos) + } + // create white image and paste image over the top + // preventing black backgrounds for transparent GIF/PNG images + dst := imaging.New(thumbWidth, thumbHeight, color.White) + // paste the original over the top + dst = imaging.OverlayCenter(dst, dstImageFill, 1.0) + + if err := jpeg.Encode(foo, dst, &jpeg.Options{Quality: 70}); err != nil { + logger.Log().Warning(err) + blankImage(a, w) + return + } + + w.Header().Add("Content-Type", "image/jpeg") + w.Header().Set("Content-Disposition", "filename=\""+fileName+"\"") + _, _ = w.Write(b.Bytes()) +} + +// Return a blank image instead of an error when file or image not supported +func blankImage(a *enmime.Part, w http.ResponseWriter) { + rect := image.Rect(0, 0, thumbWidth, thumbHeight) + img := image.NewRGBA(rect) + background := color.RGBA{255, 255, 255, 255} + draw.Draw(img, img.Bounds(), &image.Uniform{background}, image.ZP, draw.Src) + var b bytes.Buffer + foo := bufio.NewWriter(&b) + dstImageFill := imaging.Fill(img, thumbWidth, thumbHeight, imaging.Center, imaging.Lanczos) + + if err := jpeg.Encode(foo, dstImageFill, &jpeg.Options{Quality: 70}); err != nil { + logger.Log().Warning(err) + } + + fileName := a.FileName + if fileName == "" { + fileName = a.ContentID + } + + w.Header().Add("Content-Type", "image/jpeg") + w.Header().Set("Content-Disposition", "filename=\""+fileName+"\"") + _, _ = w.Write(b.Bytes()) +} diff --git a/server/ui-src/assets/styles.scss b/server/ui-src/assets/styles.scss index 3bf847b..674bb5a 100644 --- a/server/ui-src/assets/styles.scss +++ b/server/ui-src/assets/styles.scss @@ -38,14 +38,14 @@ color: $gray-500; } -#nav-plain-text, +#nav-plain-text .text-view, #nav-source { white-space: pre; font-family: Courier New, Courier, System, fixed-width; font-size: 0.85em; } -#nav-plain-text { +#nav-plain-text .text-view { white-space: pre-wrap; } @@ -96,6 +96,46 @@ body.blur { } } +.card.attachment { + color: $gray-800; + + .icon { + position: absolute; + top: 18px; + left: 0; + right: 0; + font-size: 3.5rem; + text-align: center; + color: $gray-300; + } + + .card-body { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + overflow: hidden; + opacity: 0; + } + + .card-footer { + background: $gray-300; + + .bi { + font-size: 1.3em; + margin-left: -10px; + } + } + + &:hover { + .card-body { + opacity: 1; + background: $gray-300; + } + } +} + /* PrismJS 1.29.0 - modified! https://prismjs.com/download.html#themes=prism-coy&languages=markup+css */ code[class*="language-"], diff --git a/server/ui-src/mixins.js b/server/ui-src/mixins.js index a4ff61c..b69bd15 100644 --- a/server/ui-src/mixins.js +++ b/server/ui-src/mixins.js @@ -8,127 +8,183 @@ FakeModal.prototype.show = function () { alert('open fake modal') } /* Common mixin functions used in apps */ const commonMixins = { - data() { - return { - loading: 0, - } - }, + data() { + return { + loading: 0, + } + }, - methods: { - getFileSize: function (bytes) { - var i = Math.floor(Math.log(bytes) / Math.log(1024)); - return (bytes / Math.pow(1024, i)).toFixed(1) * 1 + ' ' + ['B', 'kB', 'MB', 'GB', 'TB'][i]; - }, + methods: { + getFileSize: function (bytes) { + var i = Math.floor(Math.log(bytes) / Math.log(1024)); + return (bytes / Math.pow(1024, i)).toFixed(1) * 1 + ' ' + ['B', 'kB', 'MB', 'GB', 'TB'][i]; + }, - formatNumber: function (nr) { - return new Intl.NumberFormat().format(nr); - }, + formatNumber: function (nr) { + return new Intl.NumberFormat().format(nr); + }, - // Ajax error message - handleError: function (error) { - // handle error - if (error.response && error.response.data) { - // The request was made and the server responded with a status code - // that falls out of the range of 2xx - if (error.response.data.Error) { - alert(error.response.data.Error) - } else { - alert(error.response.data); - } - } else if (error.request) { - // The request was made but no response was received - // `error.request` is an instance of XMLHttpRequest in the browser and an instance of - // http.ClientRequest in node.js - alert('Error sending data to the server. Please try again.'); - } else { - // Something happened in setting up the request that triggered an Error - alert(error.message); - } - }, + // Ajax error message + handleError: function (error) { + // handle error + if (error.response && error.response.data) { + // The request was made and the server responded with a status code + // that falls out of the range of 2xx + if (error.response.data.Error) { + alert(error.response.data.Error) + } else { + alert(error.response.data); + } + } else if (error.request) { + // The request was made but no response was received + // `error.request` is an instance of XMLHttpRequest in the browser and an instance of + // http.ClientRequest in node.js + alert('Error sending data to the server. Please try again.'); + } else { + // Something happened in setting up the request that triggered an Error + alert(error.message); + } + }, - // generic modal get/set function - modal: function (id) { - let e = document.getElementById(id); - if (e) { - return bootstrap.Modal.getOrCreateInstance(e); - } - // in case there are open/close actions - return new FakeModal(); - }, + // generic modal get/set function + modal: function (id) { + let e = document.getElementById(id); + if (e) { + return bootstrap.Modal.getOrCreateInstance(e); + } + // in case there are open/close actions + return new FakeModal(); + }, - // generic modal get/set function - offcanvas: function (id) { - var e = document.getElementById(id); - if (e) { - return bootstrap.Offcanvas.getOrCreateInstance(e); - } - // in case there are open/close actions - return new FakeModal(); - }, + // generic modal get/set function + offcanvas: function (id) { + var e = document.getElementById(id); + if (e) { + return bootstrap.Offcanvas.getOrCreateInstance(e); + } + // in case there are open/close actions + return new FakeModal(); + }, - /** - * Axios GET request - * - * @params string url - * @params array array parameters Object/array - * @params function callback function - */ - get: function (url, values, callback) { - let self = this; - self.loading++; - axios.get(url, { params: values }) - .then(callback) - .catch(self.handleError) - .then(function () { - // always executed - if (self.loading > 0) { - self.loading--; - } - }); - }, + /** + * Axios GET request + * + * @params string url + * @params array array parameters Object/array + * @params function callback function + */ + get: function (url, values, callback) { + let self = this; + self.loading++; + axios.get(url, { params: values }) + .then(callback) + .catch(self.handleError) + .then(function () { + // always executed + if (self.loading > 0) { + self.loading--; + } + }); + }, - /** - * Axios Post request - * - * @params string url - * @params array array parameters Object/array - * @params function callback function - */ - post: function (url, values, callback) { - let self = this; - self.loading++; - axios.post(url, values) - .then(callback) - .catch(self.handleError) - .then(function () { - // always executed - if (self.loading > 0) { - self.loading--; - } - }); - }, + /** + * Axios Post request + * + * @params string url + * @params array array parameters Object/array + * @params function callback function + */ + post: function (url, values, callback) { + let self = this; + self.loading++; + axios.post(url, values) + .then(callback) + .catch(self.handleError) + .then(function () { + // always executed + if (self.loading > 0) { + self.loading--; + } + }); + }, - /** - * Axios DELETE request (REST only) - * - * @params string url - * @params array array parameters Object/array - * @params function callback function - */ - delete: function (url, values, callback) { - let self = this; - self.loading++; - axios.delete(url, { data: values }) - .then(callback) - .catch(self.handleError) - .then(function () { - // always executed - if (self.loading > 0) { - self.loading--; - } - }); - } - } + /** + * Axios DELETE request (REST only) + * + * @params string url + * @params array array parameters Object/array + * @params function callback function + */ + delete: function (url, values, callback) { + let self = this; + self.loading++; + axios.delete(url, { data: values }) + .then(callback) + .catch(self.handleError) + .then(function () { + // always executed + if (self.loading > 0) { + self.loading--; + } + }); + }, + + allAttachments: function (message) { + let a = []; + for (let i in message.Attachments) { + a.push(message.Attachments[i]); + } + for (let i in message.OtherParts) { + a.push(message.OtherParts[i]); + } + for (let i in message.Inline) { + a.push(message.Inline[i]); + } + + return a.length ? a : false; + }, + + isImage(a) { + return a.ContentType.match(/^image\//); + }, + + attachmentIcon: function (a) { + let ext = a.FileName.split('.').pop().toLowerCase(); + + if (a.ContentType.match(/^image\//)) { + return 'bi-file-image-fill'; + } + if (a.ContentType.match(/\/pdf$/) || ext == 'pdf') { + return 'bi-file-pdf-fill'; + } + if (['doc', 'docx', 'odt', 'rtf'].includes(ext)) { + return 'bi-file-word-fill'; + } + if (['xls', 'xlsx', 'ods'].includes(ext)) { + return 'bi-file-spreadsheet-fill'; + } + if (['ppt', 'pptx', 'key', 'ppt', 'odp'].includes(ext)) { + return 'bi-file-slides-fill'; + } + if (['zip', 'tar', 'rar', 'bz2', 'gz', 'xz'].includes(ext)) { + return 'bi-file-zip-fill'; + } + if (a.ContentType.match(/^audio\//)) { + return 'bi-file-music-fill'; + } + if (a.ContentType.match(/^video\//)) { + return 'bi-file-play-fill'; + } + if (a.ContentType.match(/\/calendar$/)) { + return 'bi-file-check-fill'; + } + if (a.ContentType.match(/^text\//) || ['txt', 'sh', 'log'].includes(ext)) { + return 'bi-file-text-fill'; + } + + return 'bi-file-arrow-down-fill'; + } + } } diff --git a/server/ui-src/templates/Attachments.vue b/server/ui-src/templates/Attachments.vue new file mode 100644 index 0000000..07df5d8 --- /dev/null +++ b/server/ui-src/templates/Attachments.vue @@ -0,0 +1,37 @@ + + + + + + diff --git a/server/ui-src/templates/Message.vue b/server/ui-src/templates/Message.vue index 9914d63..3fd1d69 100644 --- a/server/ui-src/templates/Message.vue +++ b/server/ui-src/templates/Message.vue @@ -3,12 +3,17 @@ import commonMixins from '../mixins.js'; import moment from 'moment'; import Prism from "prismjs"; +import Attachments from './Attachments.vue'; export default { props: { message: Object }, + components: { + Attachments + }, + mixins: [commonMixins], data() { @@ -96,21 +101,6 @@ export default { } }, - allAttachments: function(message){ - let a = []; - for (let i in message.Attachments) { - a.push(message.Attachments[i]); - } - for (let i in message.OtherParts) { - a.push(message.OtherParts[i]); - } - for (let i in message.Inline) { - a.push(message.Inline[i]); - } - - return a.length ? a : false; - }, - messageDate: function(d) { return moment(d).format('ddd, D MMM YYYY, h:mm a'); } @@ -186,7 +176,7 @@ export default {