mirror of
https://github.com/axllent/mailpit.git
synced 2025-03-17 21:18:19 +02:00
parent
5baa598453
commit
a37da776d7
6
package-lock.json
generated
6
package-lock.json
generated
@ -13,6 +13,7 @@
|
||||
"bootstrap-icons": "^1.9.1",
|
||||
"bootstrap5-tags": "^1.6.1",
|
||||
"color-hash": "^2.0.2",
|
||||
"modern-screenshot": "^4.4.30",
|
||||
"moment": "^2.29.4",
|
||||
"prismjs": "^1.29.0",
|
||||
"rapidoc": "^9.3.4",
|
||||
@ -1717,6 +1718,11 @@
|
||||
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
||||
"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": {
|
||||
"version": "2.29.4",
|
||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz",
|
||||
|
@ -14,6 +14,7 @@
|
||||
"bootstrap-icons": "^1.9.1",
|
||||
"bootstrap5-tags": "^1.6.1",
|
||||
"color-hash": "^2.0.2",
|
||||
"modern-screenshot": "^4.4.30",
|
||||
"moment": "^2.29.4",
|
||||
"prismjs": "^1.29.0",
|
||||
"rapidoc": "^9.3.4",
|
||||
|
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()
|
||||
|
||||
// kubernetes probes
|
||||
r.HandleFunc("/livez", handlers.HealthzHandler)
|
||||
r.HandleFunc("/readyz", handlers.ReadyzHandler(isReady))
|
||||
r.HandleFunc(config.Webroot+"livez", handlers.HealthzHandler)
|
||||
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
|
||||
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 MessageSummary from './templates/MessageSummary.vue'
|
||||
import MessageRelease from './templates/MessageRelease.vue'
|
||||
import MessageScreenshot from './templates/MessageScreenshot.vue'
|
||||
import MessageToast from './templates/MessageToast.vue'
|
||||
import ThemeToggle from './templates/ThemeToggle.vue'
|
||||
import moment from 'moment'
|
||||
@ -15,6 +16,7 @@ export default {
|
||||
Message,
|
||||
MessageSummary,
|
||||
MessageRelease,
|
||||
MessageScreenshot,
|
||||
MessageToast,
|
||||
ThemeToggle,
|
||||
},
|
||||
@ -697,6 +699,10 @@ export default {
|
||||
|
||||
clearMessageToast: function () {
|
||||
this.toastMessage = false
|
||||
},
|
||||
|
||||
screenshotMessageHTML: function () {
|
||||
this.$refs.MessageScreenshotRef.initScreenshot()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -752,29 +758,42 @@ export default {
|
||||
HTML body
|
||||
</button>
|
||||
</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">
|
||||
<button v-on:click="downloadMessageBody(message.Text, 'txt')" class="dropdown-item">
|
||||
Text body
|
||||
</button>
|
||||
</li>
|
||||
<li v-if="allAttachments(message).length">
|
||||
<hr class="dropdown-divider">
|
||||
</li>
|
||||
<li v-for="part in allAttachments(message)">
|
||||
<a :href="'api/v1/message/' + message.ID + '/part/' + part.PartID" type="button"
|
||||
class="row m-0 dropdown-item d-flex" target="_blank"
|
||||
:title="part.FileName != '' ? part.FileName : '[ unknown ]'" style="min-width: 350px">
|
||||
<div class="col-auto p-0 pe-1">
|
||||
<i class="bi" :class="attachmentIcon(part)"></i>
|
||||
</div>
|
||||
<div class="col text-truncate p-0 pe-1">
|
||||
{{ part.FileName != '' ? part.FileName : '[ unknown ]' }}
|
||||
</div>
|
||||
<div class="col-auto text-muted small p-0">
|
||||
{{ getFileSize(part.Size) }}
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
<template v-if="allAttachments(message).length">
|
||||
<li>
|
||||
<hr class="dropdown-divider">
|
||||
</li>
|
||||
<li>
|
||||
<h6 class="dropdown-header">
|
||||
Attachment<template v-if="allAttachments(message).length > 1">s</template>
|
||||
</h6>
|
||||
</li>
|
||||
<li v-for="part in allAttachments(message)">
|
||||
<a :href="'api/v1/message/' + message.ID + '/part/' + part.PartID" type="button"
|
||||
class="row m-0 dropdown-item d-flex" target="_blank"
|
||||
:title="part.FileName != '' ? part.FileName : '[ unknown ]'" style="min-width: 350px">
|
||||
<div class="col-auto p-0 pe-1">
|
||||
<i class="bi" :class="attachmentIcon(part)"></i>
|
||||
</div>
|
||||
<div class="col text-truncate p-0 pe-1">
|
||||
{{ part.FileName != '' ? part.FileName : '[ unknown ]' }}
|
||||
</div>
|
||||
<div class="col-auto text-muted small p-0">
|
||||
{{ getFileSize(part.Size) }}
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@ -1203,4 +1222,6 @@ export default {
|
||||
</div>
|
||||
|
||||
<MessageToast v-if="toastMessage" :message="toastMessage" @clearMessageToast="clearMessageToast"></MessageToast>
|
||||
|
||||
<MessageScreenshot ref="MessageScreenshotRef" :message="message"></MessageScreenshot>
|
||||
</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