1
0
mirror of https://github.com/axllent/mailpit.git synced 2025-06-15 00:05:15 +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 { if len(os.Getenv("MP_SMTP_SSL_KEY")) > 0 {
config.SMTPSSLKey = os.Getenv("MP_SMTP_SSL_KEY") 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 // deprecated 2022/08/06
if len(os.Getenv("MP_AUTH_FILE")) > 0 { 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.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().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().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.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") 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 ( import (
"errors" "errors"
"fmt" "fmt"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strings"
"github.com/tg123/go-htpasswd" "github.com/tg123/go-htpasswd"
) )
@ -44,6 +46,9 @@ var (
// UIAuth used for euthentication // UIAuth used for euthentication
UIAuth *htpasswd.File UIAuth *htpasswd.File
// Webroot to define the base path for the UI and API
Webroot = "/"
// SMTPSSLCert file // SMTPSSLCert file
SMTPSSLCert string SMTPSSLCert string
@ -139,6 +144,16 @@ func VerifyConfig() error {
SMTPAuth = a 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 return nil
} }

View File

@ -34,10 +34,17 @@ func Listen() {
r := defaultRoutes() r := defaultRoutes()
// web UI websocket // web UI websocket
r.HandleFunc("/api/events", apiWebsocket).Methods("GET") r.HandleFunc(config.Webroot+"api/events", apiWebsocket).Methods("GET")
// virtual filesystem for others // 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) http.Handle("/", r)
if config.UIAuthFile != "" { if config.UIAuthFile != "" {
@ -45,10 +52,10 @@ func Listen() {
} }
if config.UISSLCert != "" && config.UISSLKey != "" { 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)) logger.Log().Fatal(http.ListenAndServeTLS(config.HTTPListen, config.UISSLCert, config.UISSLKey, nil))
} else { } 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)) logger.Log().Fatal(http.ListenAndServe(config.HTTPListen, nil))
} }
} }
@ -57,16 +64,16 @@ func defaultRoutes() *mux.Router {
r := mux.NewRouter() r := mux.NewRouter()
// API V1 // API V1
r.HandleFunc("/api/v1/messages", middleWareFunc(apiv1.GetMessages)).Methods("GET") r.HandleFunc(config.Webroot+"api/v1/messages", middleWareFunc(apiv1.GetMessages)).Methods("GET")
r.HandleFunc("/api/v1/messages", middleWareFunc(apiv1.SetReadStatus)).Methods("PUT") r.HandleFunc(config.Webroot+"api/v1/messages", middleWareFunc(apiv1.SetReadStatus)).Methods("PUT")
r.HandleFunc("/api/v1/messages", middleWareFunc(apiv1.DeleteMessages)).Methods("DELETE") r.HandleFunc(config.Webroot+"api/v1/messages", middleWareFunc(apiv1.DeleteMessages)).Methods("DELETE")
r.HandleFunc("/api/v1/search", middleWareFunc(apiv1.Search)).Methods("GET") r.HandleFunc(config.Webroot+"api/v1/search", middleWareFunc(apiv1.Search)).Methods("GET")
r.HandleFunc("/api/v1/message/{id}/part/{partID}", middleWareFunc(apiv1.DownloadAttachment)).Methods("GET") r.HandleFunc(config.Webroot+"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(config.Webroot+"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(config.Webroot+"api/v1/message/{id}/raw", middleWareFunc(apiv1.DownloadRaw)).Methods("GET")
r.HandleFunc("/api/v1/message/{id}/headers", middleWareFunc(apiv1.Headers)).Methods("GET") r.HandleFunc(config.Webroot+"api/v1/message/{id}/headers", middleWareFunc(apiv1.Headers)).Methods("GET")
r.HandleFunc("/api/v1/message/{id}", middleWareFunc(apiv1.GetMessage)).Methods("GET") r.HandleFunc(config.Webroot+"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/info", middleWareFunc(apiv1.AppInfo)).Methods("GET")
return r 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 // Websocket to broadcast changes
func apiWebsocket(w http.ResponseWriter, r *http.Request) { func apiWebsocket(w http.ResponseWriter, r *http.Request) {
websockets.ServeWs(websockets.MessageHub, w, r) websockets.ServeWs(websockets.MessageHub, w, r)

View File

@ -195,14 +195,14 @@ export default {
if (a.ContentID != '') { if (a.ContentID != '') {
d.HTML = d.HTML.replace( d.HTML = d.HTML.replace(
new RegExp('cid:' + a.ContentID, 'g'), 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\_\-\.]+$/)) { if (a.FileName.match(/^[a-zA-Z0-9\_\-\.]+$/)) {
// some old email clients use the filename // some old email clients use the filename
d.HTML = d.HTML.replace( d.HTML = d.HTML.replace(
new RegExp('src=(\'|")' + a.FileName + '(\'|")', 'g'), 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 != '') { if (a.ContentID != '') {
d.HTML = d.HTML.replace( d.HTML = d.HTML.replace(
new RegExp('cid:' + a.ContentID, 'g'), 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\_\-\.]+$/)) { if (a.FileName.match(/^[a-zA-Z0-9\_\-\.]+$/)) {
// some old email clients use the filename // some old email clients use the filename
d.HTML = d.HTML.replace( d.HTML = d.HTML.replace(
new RegExp('src=(\'|")' + a.FileName + '(\'|")', 'g'), 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>
<div class="col col-md-9 col-lg-10" v-if="message"> <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> <i class="bi bi-arrow-return-left"></i>
</a> </a>
<button class="btn btn-outline-light me-2" title="Mark unread" v-on:click="markUnread"> <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"> <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> <i class="bi bi-trash-fill"></i> <span class="d-none d-md-inline">Delete</span>
</button> </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"> title="View next message">
<i class="bi bi-caret-right-fill"></i> <i class="bi bi-caret-right-fill"></i>
</a> </a>
<a class="btn btn-outline-light ms-2 me-1 float-end" :class="messagePrev ? '': 'disabled'" <a class="btn btn-outline-light ms-2 me-1 float-end" :class="messagePrev ? '' : 'disabled'"
:href="'#'+messagePrev" title="View previous message"> :href="'#' + messagePrev" title="View previous message">
<i class="bi bi-caret-left-fill"></i> <i class="bi bi-caret-left-fill"></i>
</a> </a>
<a :href="'api/v1/message/' + message.ID + '/raw?dl=1'" class="btn btn-outline-light me-2 float-end" <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> <span v-else>
<small> <small>
{{ formatNumber(start + 1) }}-{{ formatNumber(start + items.length) }} <small>of</small> {{ {{ formatNumber(start + 1) }}-{{ formatNumber(start + items.length) }} <small>of</small> {{
formatNumber(total) }} formatNumber(total)
}}
</small> </small>
<button class="btn btn-outline-light ms-2 me-1" :disabled="!canPrev" v-on:click="viewPrev" <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> <i class="bi bi-caret-left-fill"></i>
</button> </button>
<button class="btn btn-outline-light" :disabled="!canNext" v-on:click="viewNext" v-if="!searching" <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> <i class="bi bi-caret-right-fill"></i>
</button> </button>
</span> </span>
@ -635,8 +636,8 @@ export default {
</li> </li>
<li class="my-3" v-if="selected.length > 0"> <li class="my-3" v-if="selected.length > 0">
<b class="me-2">Selected {{selected.length}}</b> <b class="me-2">Selected {{ selected.length }}</b>
<button class="btn btn-sm text-muted" v-on:click="selected=[]" title="Unselect messages"><i <button class="btn btn-sm text-muted" v-on:click="selected = []" title="Unselect messages"><i
class="bi bi-x-circle"></i></button> class="bi bi-x-circle"></i></button>
</li> </li>
<li class="my-3 ms-2" v-if="selected.length > 0 && selectedHasUnread()"> <li class="my-3 ms-2" v-if="selected.length > 0 && selectedHasUnread()">
@ -675,13 +676,13 @@ export default {
</div> </div>
<div class="col-lg-10 col-md-9 mh-100 pe-0"> <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"> <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.ctrl="toggleSelected($event, message.ID)"
v-on:click.shift="selectRange($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="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="col-lg-3">
<div class="d-lg-none float-end text-muted text-nowrap small"> <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> <i class="bi bi-paperclip h6 me-1" v-if="message.Attachments"></i>
@ -689,16 +690,18 @@ export default {
</div> </div>
<div class="text-truncate d-lg-none privacy"> <div class="text-truncate d-lg-none privacy">
<span v-if="message.From" :title="message.From.Address">{{ message.From.Name ? <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>
<div class="text-truncate d-none d-lg-block privacy"> <div class="text-truncate d-none d-lg-block privacy">
<b v-if="message.From" :title="message.From.Address">{{ message.From.Name ? <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>
<div class="d-none d-lg-block text-truncate text-muted small privacy"> <div class="d-none d-lg-block text-truncate text-muted small privacy">
{{ getPrimaryEmailTo(message) }} {{ getPrimaryEmailTo(message) }}
<span v-if="message.To && message.To.length > 1"> <span v-if="message.To && message.To.length > 1">
[+{{message.To.length - 1}}] [+{{ message.To.length - 1 }}]
</span> </span>
</div> </div>
</div> </div>
@ -815,7 +818,7 @@ export default {
</div> </div>
<div class="modal-body"> <div class="modal-body">
<a class="btn btn-warning d-block mb-3" v-if="appInfo.Version != appInfo.LatestVersion" <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 new version of Mailpit ({{ appInfo.LatestVersion }}) is available.
</a> </a>