1
0
mirror of https://github.com/axllent/mailpit.git synced 2025-01-26 03:52:09 +02:00

Feature: Allow custom webroot

Allow Mailpit to run on a custom webroot, resolves #19
This commit is contained in:
Ralph Slooten 2022-10-31 22:13:41 +13:00
parent ab771cf76c
commit cbc3fe59a8
4 changed files with 68 additions and 34 deletions

View File

@ -103,6 +103,9 @@ func init() {
if len(os.Getenv("MP_SMTP_SSL_KEY")) > 0 {
config.SMTPSSLKey = os.Getenv("MP_SMTP_SSL_KEY")
}
if len(os.Getenv("MP_WEBROOT")) > 0 {
config.Webroot = os.Getenv("MP_WEBROOT")
}
// deprecated 2022/08/06
if len(os.Getenv("MP_AUTH_FILE")) > 0 {
@ -127,6 +130,7 @@ func init() {
rootCmd.Flags().StringVarP(&config.SMTPListen, "smtp", "s", config.SMTPListen, "SMTP bind interface and port")
rootCmd.Flags().StringVarP(&config.HTTPListen, "listen", "l", config.HTTPListen, "HTTP bind interface and port for UI")
rootCmd.Flags().IntVarP(&config.MaxMessages, "max", "m", config.MaxMessages, "Max number of messages to store")
rootCmd.Flags().StringVar(&config.Webroot, "webroot", config.Webroot, "Set the webroot for web UI & API")
rootCmd.Flags().StringVar(&config.UIAuthFile, "ui-auth-file", config.UIAuthFile, "A password file for web UI authentication")
rootCmd.Flags().StringVar(&config.UISSLCert, "ui-ssl-cert", config.UISSLCert, "SSL certificate for web UI - requires ui-ssl-key")

View File

@ -3,9 +3,11 @@ package config
import (
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/tg123/go-htpasswd"
)
@ -44,6 +46,9 @@ var (
// UIAuth used for euthentication
UIAuth *htpasswd.File
// Webroot to define the base path for the UI and API
Webroot = "/"
// SMTPSSLCert file
SMTPSSLCert string
@ -139,6 +144,16 @@ func VerifyConfig() error {
SMTPAuth = a
}
if strings.Contains(Webroot, " ") {
return fmt.Errorf("Webroot cannot contain spaces (%s)", Webroot)
}
s, err := url.JoinPath("/", Webroot, "/")
if err != nil {
return err
}
Webroot = s
return nil
}

View File

@ -34,10 +34,17 @@ func Listen() {
r := defaultRoutes()
// web UI websocket
r.HandleFunc("/api/events", apiWebsocket).Methods("GET")
r.HandleFunc(config.Webroot+"api/events", apiWebsocket).Methods("GET")
// virtual filesystem for others
r.PathPrefix("/").Handler(middlewareHandler(http.FileServer(http.FS(serverRoot))))
r.PathPrefix(config.Webroot).Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
// redirect to webroot if no trailing slash
if config.Webroot != "/" {
redir := strings.TrimRight(config.Webroot, "/")
r.HandleFunc(redir, middleWareFunc(addSlashToWebroot)).Methods("GET")
}
http.Handle("/", r)
if config.UIAuthFile != "" {
@ -45,10 +52,10 @@ func Listen() {
}
if config.UISSLCert != "" && config.UISSLKey != "" {
logger.Log().Infof("[http] starting secure server on https://%s", config.HTTPListen)
logger.Log().Infof("[http] starting secure server on https://%s%s", config.HTTPListen, config.Webroot)
logger.Log().Fatal(http.ListenAndServeTLS(config.HTTPListen, config.UISSLCert, config.UISSLKey, nil))
} else {
logger.Log().Infof("[http] starting server on http://%s", config.HTTPListen)
logger.Log().Infof("[http] starting server on http://%s%s", config.HTTPListen, config.Webroot)
logger.Log().Fatal(http.ListenAndServe(config.HTTPListen, nil))
}
}
@ -57,16 +64,16 @@ func defaultRoutes() *mux.Router {
r := mux.NewRouter()
// API V1
r.HandleFunc("/api/v1/messages", middleWareFunc(apiv1.GetMessages)).Methods("GET")
r.HandleFunc("/api/v1/messages", middleWareFunc(apiv1.SetReadStatus)).Methods("PUT")
r.HandleFunc("/api/v1/messages", middleWareFunc(apiv1.DeleteMessages)).Methods("DELETE")
r.HandleFunc("/api/v1/search", middleWareFunc(apiv1.Search)).Methods("GET")
r.HandleFunc("/api/v1/message/{id}/part/{partID}", middleWareFunc(apiv1.DownloadAttachment)).Methods("GET")
r.HandleFunc("/api/v1/message/{id}/part/{partID}/thumb", middleWareFunc(apiv1.Thumbnail)).Methods("GET")
r.HandleFunc("/api/v1/message/{id}/raw", middleWareFunc(apiv1.DownloadRaw)).Methods("GET")
r.HandleFunc("/api/v1/message/{id}/headers", middleWareFunc(apiv1.Headers)).Methods("GET")
r.HandleFunc("/api/v1/message/{id}", middleWareFunc(apiv1.GetMessage)).Methods("GET")
r.HandleFunc("/api/v1/info", middleWareFunc(apiv1.AppInfo)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/messages", middleWareFunc(apiv1.GetMessages)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/messages", middleWareFunc(apiv1.SetReadStatus)).Methods("PUT")
r.HandleFunc(config.Webroot+"api/v1/messages", middleWareFunc(apiv1.DeleteMessages)).Methods("DELETE")
r.HandleFunc(config.Webroot+"api/v1/search", middleWareFunc(apiv1.Search)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/message/{id}/part/{partID}", middleWareFunc(apiv1.DownloadAttachment)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/message/{id}/part/{partID}/thumb", middleWareFunc(apiv1.Thumbnail)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/message/{id}/raw", middleWareFunc(apiv1.DownloadRaw)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/message/{id}/headers", middleWareFunc(apiv1.Headers)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/message/{id}", middleWareFunc(apiv1.GetMessage)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/info", middleWareFunc(apiv1.AppInfo)).Methods("GET")
return r
}
@ -152,6 +159,11 @@ func middlewareHandler(h http.Handler) http.Handler {
})
}
// Redirect to webroot
func addSlashToWebroot(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, config.Webroot, http.StatusFound)
}
// Websocket to broadcast changes
func apiWebsocket(w http.ResponseWriter, r *http.Request) {
websockets.ServeWs(websockets.MessageHub, w, r)

View File

@ -195,14 +195,14 @@ export default {
if (a.ContentID != '') {
d.HTML = d.HTML.replace(
new RegExp('cid:' + a.ContentID, 'g'),
window.location.origin + '/api/v1/message/' + d.ID + '/part/' + a.PartID
window.location.origin + window.location.pathname + 'api/v1/message/' + d.ID + '/part/' + a.PartID
);
}
if (a.FileName.match(/^[a-zA-Z0-9\_\-\.]+$/)) {
// some old email clients use the filename
d.HTML = d.HTML.replace(
new RegExp('src=(\'|")' + a.FileName + '(\'|")', 'g'),
'src="' + window.location.origin + '/api/v1/message/' + d.ID + '/part/' + a.PartID + '"'
'src="' + window.location.origin + window.location.pathname + 'api/v1/message/' + d.ID + '/part/' + a.PartID + '"'
);
}
}
@ -214,14 +214,14 @@ export default {
if (a.ContentID != '') {
d.HTML = d.HTML.replace(
new RegExp('cid:' + a.ContentID, 'g'),
window.location.origin + '/api/v1/message/' + d.ID + '/part/' + a.PartID
window.location.origin + window.location.pathname + 'api/v1/message/' + d.ID + '/part/' + a.PartID
);
}
if (a.FileName.match(/^[a-zA-Z0-9\_\-\.]+$/)) {
// some old email clients use the filename
d.HTML = d.HTML.replace(
new RegExp('src=(\'|")' + a.FileName + '(\'|")', 'g'),
'src="' + window.location.origin + '/api/v1/message/' + d.ID + '/part/' + a.PartID + '"'
'src="' + window.location.origin + window.location.pathname + 'api/v1/message/' + d.ID + '/part/' + a.PartID + '"'
);
}
}
@ -522,7 +522,7 @@ export default {
</div>
<div class="col col-md-9 col-lg-10" v-if="message">
<a class="btn btn-outline-light me-4 px-3" href="#" v-on:click="message=false" title="Return to messages">
<a class="btn btn-outline-light me-4 px-3" href="#" v-on:click="message = false" title="Return to messages">
<i class="bi bi-arrow-return-left"></i>
</a>
<button class="btn btn-outline-light me-2" title="Mark unread" v-on:click="markUnread">
@ -531,12 +531,12 @@ export default {
<button class="btn btn-outline-light me-2" title="Delete message" v-on:click="deleteMessages">
<i class="bi bi-trash-fill"></i> <span class="d-none d-md-inline">Delete</span>
</button>
<a class="btn btn-outline-light float-end" :class="messageNext ? '':'disabled'" :href="'#'+messageNext"
<a class="btn btn-outline-light float-end" :class="messageNext ? '' : 'disabled'" :href="'#' + messageNext"
title="View next message">
<i class="bi bi-caret-right-fill"></i>
</a>
<a class="btn btn-outline-light ms-2 me-1 float-end" :class="messagePrev ? '': 'disabled'"
:href="'#'+messagePrev" title="View previous message">
<a class="btn btn-outline-light ms-2 me-1 float-end" :class="messagePrev ? '' : 'disabled'"
:href="'#' + messagePrev" title="View previous message">
<i class="bi bi-caret-left-fill"></i>
</a>
<a :href="'api/v1/message/' + message.ID + '/raw?dl=1'" class="btn btn-outline-light me-2 float-end"
@ -587,14 +587,15 @@ export default {
<span v-else>
<small>
{{ formatNumber(start + 1) }}-{{ formatNumber(start + items.length) }} <small>of</small> {{
formatNumber(total) }}
formatNumber(total)
}}
</small>
<button class="btn btn-outline-light ms-2 me-1" :disabled="!canPrev" v-on:click="viewPrev"
v-if="!searching" :title="'View previous '+limit+' messages'">
v-if="!searching" :title="'View previous ' + limit + ' messages'">
<i class="bi bi-caret-left-fill"></i>
</button>
<button class="btn btn-outline-light" :disabled="!canNext" v-on:click="viewNext" v-if="!searching"
:title="'View next '+limit+' messages'">
:title="'View next ' + limit + ' messages'">
<i class="bi bi-caret-right-fill"></i>
</button>
</span>
@ -635,8 +636,8 @@ export default {
</li>
<li class="my-3" v-if="selected.length > 0">
<b class="me-2">Selected {{selected.length}}</b>
<button class="btn btn-sm text-muted" v-on:click="selected=[]" title="Unselect messages"><i
<b class="me-2">Selected {{ selected.length }}</b>
<button class="btn btn-sm text-muted" v-on:click="selected = []" title="Unselect messages"><i
class="bi bi-x-circle"></i></button>
</li>
<li class="my-3 ms-2" v-if="selected.length > 0 && selectedHasUnread()">
@ -675,13 +676,13 @@ export default {
</div>
<div class="col-lg-10 col-md-9 mh-100 pe-0">
<div class="mh-100" style="overflow-y: auto;" :class="message ? 'd-none':''" id="message-page">
<div class="mh-100" style="overflow-y: auto;" :class="message ? 'd-none' : ''" id="message-page">
<div class="list-group my-2" v-if="items.length">
<a v-for="message in items" :href="'#'+message.ID"
<a v-for="message in items" :href="'#' + message.ID"
v-on:click.ctrl="toggleSelected($event, message.ID)"
v-on:click.shift="selectRange($event, message.ID)"
class="row message d-flex small list-group-item list-group-item-action border-start-0 border-end-0"
:class="message.Read ? 'read':'', isSelected(message.ID) ? 'selected':''">
:class="message.Read ? 'read' : '', isSelected(message.ID) ? 'selected' : ''">
<div class="col-lg-3">
<div class="d-lg-none float-end text-muted text-nowrap small">
<i class="bi bi-paperclip h6 me-1" v-if="message.Attachments"></i>
@ -689,16 +690,18 @@ export default {
</div>
<div class="text-truncate d-lg-none privacy">
<span v-if="message.From" :title="message.From.Address">{{ message.From.Name ?
message.From.Name : message.From.Address }}</span>
message.From.Name : message.From.Address
}}</span>
</div>
<div class="text-truncate d-none d-lg-block privacy">
<b v-if="message.From" :title="message.From.Address">{{ message.From.Name ?
message.From.Name : message.From.Address }}</b>
message.From.Name : message.From.Address
}}</b>
</div>
<div class="d-none d-lg-block text-truncate text-muted small privacy">
{{ getPrimaryEmailTo(message) }}
<span v-if="message.To && message.To.length > 1">
[+{{message.To.length - 1}}]
[+{{ message.To.length - 1 }}]
</span>
</div>
</div>
@ -815,7 +818,7 @@ export default {
</div>
<div class="modal-body">
<a class="btn btn-warning d-block mb-3" v-if="appInfo.Version != appInfo.LatestVersion"
:href="'https://github.com/axllent/mailpit/releases/tag/'+appInfo.LatestVersion">
:href="'https://github.com/axllent/mailpit/releases/tag/' + appInfo.LatestVersion">
A new version of Mailpit ({{ appInfo.LatestVersion }}) is available.
</a>