mirror of
https://github.com/axllent/mailpit.git
synced 2025-03-29 22:07:04 +02:00
Merge branch 'feature/screenshot' into develop
This commit is contained in:
commit
1757a0086e
6
package-lock.json
generated
6
package-lock.json
generated
@ -13,6 +13,7 @@
|
|||||||
"bootstrap-icons": "^1.9.1",
|
"bootstrap-icons": "^1.9.1",
|
||||||
"bootstrap5-tags": "^1.6.1",
|
"bootstrap5-tags": "^1.6.1",
|
||||||
"color-hash": "^2.0.2",
|
"color-hash": "^2.0.2",
|
||||||
|
"modern-screenshot": "^4.4.30",
|
||||||
"moment": "^2.29.4",
|
"moment": "^2.29.4",
|
||||||
"prismjs": "^1.29.0",
|
"prismjs": "^1.29.0",
|
||||||
"rapidoc": "^9.3.4",
|
"rapidoc": "^9.3.4",
|
||||||
@ -1717,6 +1718,11 @@
|
|||||||
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"node_modules/modern-screenshot": {
|
||||||
|
"version": "4.4.30",
|
||||||
|
"resolved": "https://registry.npmjs.org/modern-screenshot/-/modern-screenshot-4.4.30.tgz",
|
||||||
|
"integrity": "sha512-rC6SC40NEP04qZqthKM+W3xttz3NuNOpyMFMZ+P//zoBxsSrQbrBGYL/Sp1h6U9TKmIzyj3vEymVwdcwl7EEiA=="
|
||||||
|
},
|
||||||
"node_modules/moment": {
|
"node_modules/moment": {
|
||||||
"version": "2.29.4",
|
"version": "2.29.4",
|
||||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz",
|
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz",
|
||||||
|
@ -14,6 +14,7 @@
|
|||||||
"bootstrap-icons": "^1.9.1",
|
"bootstrap-icons": "^1.9.1",
|
||||||
"bootstrap5-tags": "^1.6.1",
|
"bootstrap5-tags": "^1.6.1",
|
||||||
"color-hash": "^2.0.2",
|
"color-hash": "^2.0.2",
|
||||||
|
"modern-screenshot": "^4.4.30",
|
||||||
"moment": "^2.29.4",
|
"moment": "^2.29.4",
|
||||||
"prismjs": "^1.29.0",
|
"prismjs": "^1.29.0",
|
||||||
"rapidoc": "^9.3.4",
|
"rapidoc": "^9.3.4",
|
||||||
|
@ -2,7 +2,7 @@ package handlers
|
|||||||
|
|
||||||
import "net/http"
|
import "net/http"
|
||||||
|
|
||||||
// Healthz is a liveness probe
|
// HealthzHandler is a liveness probe
|
||||||
func HealthzHandler(w http.ResponseWriter, _ *http.Request) {
|
func HealthzHandler(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}
|
}
|
||||||
|
147
server/handlers/proxy.go
Normal file
147
server/handlers/proxy.go
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
// Package handlers contains a specific handlers
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/axllent/mailpit/config"
|
||||||
|
"github.com/axllent/mailpit/utils/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
var linkRe = regexp.MustCompile(`(?i)^https?:\/\/`)
|
||||||
|
|
||||||
|
// ProxyHandler is used to proxy assets for printing
|
||||||
|
func ProxyHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uri := strings.TrimSpace(r.URL.Query().Get("url"))
|
||||||
|
if uri == "" {
|
||||||
|
logger.Log().Warn("[proxy] URL missing")
|
||||||
|
httpError(w, "Error: URL missing")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !linkRe.MatchString(uri) {
|
||||||
|
logger.Log().Warnf("[proxy] invalid URL %s", uri)
|
||||||
|
httpError(w, "Error: invalid URL")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", uri, nil)
|
||||||
|
if err != nil {
|
||||||
|
logger.Log().Warnf("[proxy] %s", err.Error())
|
||||||
|
httpError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// use requesting useragent
|
||||||
|
req.Header.Set("User-Agent", r.UserAgent())
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
logger.Log().Warnf("[proxy] %s", err.Error())
|
||||||
|
httpError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
logger.Log().Warnf("[proxy] %s", err.Error())
|
||||||
|
httpError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// relay common headers
|
||||||
|
if resp.Header.Get("content-type") != "" {
|
||||||
|
w.Header().Set("content-type", resp.Header.Get("content-type"))
|
||||||
|
}
|
||||||
|
if resp.Header.Get("last-modified") != "" {
|
||||||
|
w.Header().Set("last-modified", resp.Header.Get("last-modified"))
|
||||||
|
}
|
||||||
|
if resp.Header.Get("content-disposition") != "" {
|
||||||
|
w.Header().Set("content-disposition", resp.Header.Get("content-disposition"))
|
||||||
|
}
|
||||||
|
if resp.Header.Get("cache-control") != "" {
|
||||||
|
w.Header().Set("cache-control", resp.Header.Get("cache-control"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// replace url() values with proxy address, eg: fonts & images
|
||||||
|
if strings.HasPrefix(resp.Header.Get("content-type"), "text/css") {
|
||||||
|
var re = regexp.MustCompile(`(?mi)(url\((\'|\")?([^\)\'\"]+)(\'|\")?\))`)
|
||||||
|
body = re.ReplaceAllFunc(body, func(s []byte) []byte {
|
||||||
|
parts := re.FindStringSubmatch(string(s))
|
||||||
|
|
||||||
|
// don't resolve inline `data:..`
|
||||||
|
if strings.HasPrefix(parts[3], "data:") {
|
||||||
|
return []byte(parts[3])
|
||||||
|
}
|
||||||
|
|
||||||
|
address, err := absoluteURL(parts[3], uri)
|
||||||
|
if err != nil {
|
||||||
|
logger.Log().Error(err)
|
||||||
|
return []byte(parts[3])
|
||||||
|
}
|
||||||
|
|
||||||
|
return []byte("url(" + parts[2] + config.Webroot + "proxy?url=" + url.QueryEscape(address) + parts[4] + ")")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Log().Debugf("[proxy] %s (%d)", uri, resp.StatusCode)
|
||||||
|
|
||||||
|
// relay status code - WriteHeader must come after Header.Set()
|
||||||
|
w.WriteHeader(resp.StatusCode)
|
||||||
|
|
||||||
|
w.Write(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AbsoluteURL will return a full URL regardless whether it is relative or absolute
|
||||||
|
func absoluteURL(link, baseURL string) (string, error) {
|
||||||
|
// scheme relative links, eg <script src="//example.com/script.js">
|
||||||
|
if len(link) > 1 && link[0:2] == "//" {
|
||||||
|
base, err := url.Parse(baseURL)
|
||||||
|
if err != nil {
|
||||||
|
return link, err
|
||||||
|
}
|
||||||
|
link = base.Scheme + ":" + link
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := url.Parse(link)
|
||||||
|
if err != nil {
|
||||||
|
return link, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove hashes
|
||||||
|
u.Fragment = ""
|
||||||
|
|
||||||
|
base, err := url.Parse(baseURL)
|
||||||
|
if err != nil {
|
||||||
|
return link, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := base.ResolveReference(u)
|
||||||
|
|
||||||
|
// ensure link is HTTP(S)
|
||||||
|
if result.Scheme != "http" && result.Scheme != "https" {
|
||||||
|
return link, fmt.Errorf("Invalid URL: %s", result.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTPError returns a basic error message (400 response)
|
||||||
|
func httpError(w http.ResponseWriter, msg string) {
|
||||||
|
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||||
|
w.Header().Set("Content-Security-Policy", config.ContentSecurityPolicy)
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
w.Header().Set("Content-Type", "text/plain")
|
||||||
|
fmt.Fprint(w, msg)
|
||||||
|
}
|
@ -45,8 +45,11 @@ func Listen() {
|
|||||||
r := defaultRoutes()
|
r := defaultRoutes()
|
||||||
|
|
||||||
// kubernetes probes
|
// kubernetes probes
|
||||||
r.HandleFunc("/livez", handlers.HealthzHandler)
|
r.HandleFunc(config.Webroot+"livez", handlers.HealthzHandler)
|
||||||
r.HandleFunc("/readyz", handlers.ReadyzHandler(isReady))
|
r.HandleFunc(config.Webroot+"readyz", handlers.ReadyzHandler(isReady))
|
||||||
|
|
||||||
|
// proxy handler for screenshots
|
||||||
|
r.HandleFunc(config.Webroot+"proxy", middleWareFunc(handlers.ProxyHandler)).Methods("GET")
|
||||||
|
|
||||||
// web UI websocket
|
// web UI websocket
|
||||||
r.HandleFunc(config.Webroot+"api/events", apiWebsocket).Methods("GET")
|
r.HandleFunc(config.Webroot+"api/events", apiWebsocket).Methods("GET")
|
||||||
|
@ -3,6 +3,7 @@ import commonMixins from './mixins.js'
|
|||||||
import Message from './templates/Message.vue'
|
import Message from './templates/Message.vue'
|
||||||
import MessageSummary from './templates/MessageSummary.vue'
|
import MessageSummary from './templates/MessageSummary.vue'
|
||||||
import MessageRelease from './templates/MessageRelease.vue'
|
import MessageRelease from './templates/MessageRelease.vue'
|
||||||
|
import MessageScreenshot from './templates/MessageScreenshot.vue'
|
||||||
import MessageToast from './templates/MessageToast.vue'
|
import MessageToast from './templates/MessageToast.vue'
|
||||||
import ThemeToggle from './templates/ThemeToggle.vue'
|
import ThemeToggle from './templates/ThemeToggle.vue'
|
||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
@ -15,6 +16,7 @@ export default {
|
|||||||
Message,
|
Message,
|
||||||
MessageSummary,
|
MessageSummary,
|
||||||
MessageRelease,
|
MessageRelease,
|
||||||
|
MessageScreenshot,
|
||||||
MessageToast,
|
MessageToast,
|
||||||
ThemeToggle,
|
ThemeToggle,
|
||||||
},
|
},
|
||||||
@ -697,6 +699,10 @@ export default {
|
|||||||
|
|
||||||
clearMessageToast: function () {
|
clearMessageToast: function () {
|
||||||
this.toastMessage = false
|
this.toastMessage = false
|
||||||
|
},
|
||||||
|
|
||||||
|
screenshotMessageHTML: function () {
|
||||||
|
this.$refs.MessageScreenshotRef.initScreenshot()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -752,29 +758,42 @@ export default {
|
|||||||
HTML body
|
HTML body
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
<!-- <MessageScreenshot :message="message"></MessageScreenshot> -->
|
||||||
|
<li v-if="message.HTML">
|
||||||
|
<button class="dropdown-item" @click="screenshotMessageHTML()">
|
||||||
|
HTML screenshot
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
<li v-if="message.Text">
|
<li v-if="message.Text">
|
||||||
<button v-on:click="downloadMessageBody(message.Text, 'txt')" class="dropdown-item">
|
<button v-on:click="downloadMessageBody(message.Text, 'txt')" class="dropdown-item">
|
||||||
Text body
|
Text body
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li v-if="allAttachments(message).length">
|
<template v-if="allAttachments(message).length">
|
||||||
<hr class="dropdown-divider">
|
<li>
|
||||||
</li>
|
<hr class="dropdown-divider">
|
||||||
<li v-for="part in allAttachments(message)">
|
</li>
|
||||||
<a :href="'api/v1/message/' + message.ID + '/part/' + part.PartID" type="button"
|
<li>
|
||||||
class="row m-0 dropdown-item d-flex" target="_blank"
|
<h6 class="dropdown-header">
|
||||||
:title="part.FileName != '' ? part.FileName : '[ unknown ]'" style="min-width: 350px">
|
Attachment<template v-if="allAttachments(message).length > 1">s</template>
|
||||||
<div class="col-auto p-0 pe-1">
|
</h6>
|
||||||
<i class="bi" :class="attachmentIcon(part)"></i>
|
</li>
|
||||||
</div>
|
<li v-for="part in allAttachments(message)">
|
||||||
<div class="col text-truncate p-0 pe-1">
|
<a :href="'api/v1/message/' + message.ID + '/part/' + part.PartID" type="button"
|
||||||
{{ part.FileName != '' ? part.FileName : '[ unknown ]' }}
|
class="row m-0 dropdown-item d-flex" target="_blank"
|
||||||
</div>
|
:title="part.FileName != '' ? part.FileName : '[ unknown ]'" style="min-width: 350px">
|
||||||
<div class="col-auto text-muted small p-0">
|
<div class="col-auto p-0 pe-1">
|
||||||
{{ getFileSize(part.Size) }}
|
<i class="bi" :class="attachmentIcon(part)"></i>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
<div class="col text-truncate p-0 pe-1">
|
||||||
</li>
|
{{ part.FileName != '' ? part.FileName : '[ unknown ]' }}
|
||||||
|
</div>
|
||||||
|
<div class="col-auto text-muted small p-0">
|
||||||
|
{{ getFileSize(part.Size) }}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -1203,4 +1222,6 @@ export default {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MessageToast v-if="toastMessage" :message="toastMessage" @clearMessageToast="clearMessageToast"></MessageToast>
|
<MessageToast v-if="toastMessage" :message="toastMessage" @clearMessageToast="clearMessageToast"></MessageToast>
|
||||||
|
|
||||||
|
<MessageScreenshot ref="MessageScreenshotRef" :message="message"></MessageScreenshot>
|
||||||
</template>
|
</template>
|
||||||
|
135
server/ui-src/templates/MessageScreenshot.vue
Normal file
135
server/ui-src/templates/MessageScreenshot.vue
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
|
||||||
|
<script>
|
||||||
|
import { domToPng } from 'modern-screenshot'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
message: Object,
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
html: false,
|
||||||
|
loading: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
initScreenshot: function () {
|
||||||
|
this.loading = true
|
||||||
|
// remove base tag, if set
|
||||||
|
let h = this.message.HTML.replace(/<base .*>/mi, '')
|
||||||
|
|
||||||
|
// Outlook hacks - else screenshot returns blank image
|
||||||
|
h = h.replace(/<html [^>]+>/mgi, '<html>') // remove html attributes
|
||||||
|
h = h.replace(/<o:p><\/o:p>/mg, '') // remove empty `<o:p></o:p>` tags
|
||||||
|
h = h.replace(/<o:/mg, '<') // replace `<o:p>` tags with `<p>`
|
||||||
|
h = h.replace(/<\/o:/mg, '</') // replace `</o:p>` tags with `</p>`
|
||||||
|
|
||||||
|
// update any inline `url(...)` absolute links
|
||||||
|
const urlRegex = /(url\((\'|\")?(https?:\/\/[^\)\'\"]+)(\'|\")?\))/mgi;
|
||||||
|
h = h.replaceAll(urlRegex, function (match, p1, p2, p3) {
|
||||||
|
if (typeof p2 === 'string') {
|
||||||
|
return `url(${p2}proxy?url=` + encodeURIComponent(p3) + `${p2})`
|
||||||
|
}
|
||||||
|
return `url(proxy?url=` + encodeURIComponent(p3) + `)`
|
||||||
|
})
|
||||||
|
|
||||||
|
// create temporary document to manipulate
|
||||||
|
let doc = document.implementation.createHTMLDocument();
|
||||||
|
doc.open()
|
||||||
|
doc.write(h)
|
||||||
|
doc.close()
|
||||||
|
|
||||||
|
// remove any <script> tags
|
||||||
|
let scripts = doc.getElementsByTagName('script')
|
||||||
|
for (let i of scripts) {
|
||||||
|
i.parentNode.removeChild(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// replace stylesheet links with proxy links
|
||||||
|
let stylesheets = doc.getElementsByTagName('link')
|
||||||
|
for (let i of stylesheets) {
|
||||||
|
let src = i.getAttribute('href')
|
||||||
|
|
||||||
|
if (src && src.match(/^https?:\/\//i) && src.indexOf(window.location.origin + window.location.pathname) !== 0) {
|
||||||
|
i.setAttribute('href', 'proxy?url=' + encodeURIComponent(src))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// replace images with proxy links
|
||||||
|
let images = doc.getElementsByTagName('img')
|
||||||
|
for (let i of images) {
|
||||||
|
let src = i.getAttribute('src')
|
||||||
|
if (src && src.match(/^https?:\/\//i) && src.indexOf(window.location.origin + window.location.pathname) !== 0) {
|
||||||
|
i.setAttribute('src', 'proxy?url=' + encodeURIComponent(src))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// replace background="" attributes with proxy links
|
||||||
|
let backgrounds = doc.querySelectorAll("[background]")
|
||||||
|
for (let i of backgrounds) {
|
||||||
|
let src = i.getAttribute('background')
|
||||||
|
|
||||||
|
if (src && src.match(/^https?:\/\//i) && src.indexOf(window.location.origin + window.location.pathname) !== 0) {
|
||||||
|
// replace with proxy link
|
||||||
|
i.setAttribute('background', 'proxy?url=' + encodeURIComponent(src))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// set html with manipulated document content
|
||||||
|
this.html = new XMLSerializer().serializeToString(doc)
|
||||||
|
},
|
||||||
|
|
||||||
|
doScreenshot: function () {
|
||||||
|
let self = this
|
||||||
|
|
||||||
|
let width = document.getElementById('message-view').getBoundingClientRect().width
|
||||||
|
|
||||||
|
let prev = document.getElementById('preview-html')
|
||||||
|
if (prev && prev.getBoundingClientRect().width) {
|
||||||
|
width = prev.getBoundingClientRect().width
|
||||||
|
}
|
||||||
|
|
||||||
|
if (width < 300) {
|
||||||
|
width = 300
|
||||||
|
}
|
||||||
|
|
||||||
|
let i = document.getElementById('screenshot-html')
|
||||||
|
|
||||||
|
// set the iframe width
|
||||||
|
i.style.width = width + 'px'
|
||||||
|
|
||||||
|
let body = i.contentWindow.document.querySelector('body')
|
||||||
|
|
||||||
|
// take screenshot of iframe
|
||||||
|
domToPng(body, {
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
height: i.contentWindow.document.body.scrollHeight + 20,
|
||||||
|
width: width,
|
||||||
|
}).then(dataUrl => {
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.download = self.message.ID + '.png'
|
||||||
|
link.href = dataUrl
|
||||||
|
link.click()
|
||||||
|
self.loading = false
|
||||||
|
self.html = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<iframe v-if="html" :srcdoc="html" v-on:load="doScreenshot" frameborder="0" id="screenshot-html"
|
||||||
|
style="position: absolute; margin-left: -100000px;">
|
||||||
|
</iframe>
|
||||||
|
|
||||||
|
<div id="loading" v-if="loading">
|
||||||
|
<div class="d-flex justify-content-center align-items-center h-100">
|
||||||
|
<div class="spinner-border text-secondary" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
Loading…
x
Reference in New Issue
Block a user