").append(n.parseHTML(a)).find(d):a)}).complete(c&&function(a,b){g.each(c,e||[a.responseText,b,a])}),this},n.expr.filters.animated=function(a){return n.grep(n.timers,function(b){return a===b.elem}).length};var dd=a.document.documentElement;function ed(a){return n.isWindow(a)?a:9===a.nodeType?a.defaultView||a.parentWindow:!1}n.offset={setOffset:function(a,b,c){var d,e,f,g,h,i,j,k=n.css(a,"position"),l=n(a),m={};"static"===k&&(a.style.position="relative"),h=l.offset(),f=n.css(a,"top"),i=n.css(a,"left"),j=("absolute"===k||"fixed"===k)&&n.inArray("auto",[f,i])>-1,j?(d=l.position(),g=d.top,e=d.left):(g=parseFloat(f)||0,e=parseFloat(i)||0),n.isFunction(b)&&(b=b.call(a,c,h)),null!=b.top&&(m.top=b.top-h.top+g),null!=b.left&&(m.left=b.left-h.left+e),"using"in b?b.using.call(a,m):l.css(m)}},n.fn.extend({offset:function(a){if(arguments.length)return void 0===a?this:this.each(function(b){n.offset.setOffset(this,a,b)});var b,c,d={top:0,left:0},e=this[0],f=e&&e.ownerDocument;if(f)return b=f.documentElement,n.contains(b,e)?(typeof e.getBoundingClientRect!==L&&(d=e.getBoundingClientRect()),c=ed(f),{top:d.top+(c.pageYOffset||b.scrollTop)-(b.clientTop||0),left:d.left+(c.pageXOffset||b.scrollLeft)-(b.clientLeft||0)}):d},position:function(){if(this[0]){var a,b,c={top:0,left:0},d=this[0];return"fixed"===n.css(d,"position")?b=d.getBoundingClientRect():(a=this.offsetParent(),b=this.offset(),n.nodeName(a[0],"html")||(c=a.offset()),c.top+=n.css(a[0],"borderTopWidth",!0),c.left+=n.css(a[0],"borderLeftWidth",!0)),{top:b.top-c.top-n.css(d,"marginTop",!0),left:b.left-c.left-n.css(d,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var a=this.offsetParent||dd;while(a&&!n.nodeName(a,"html")&&"static"===n.css(a,"position"))a=a.offsetParent;return a||dd})}}),n.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(a,b){var c=/Y/.test(b);n.fn[a]=function(d){return W(this,function(a,d,e){var f=ed(a);return void 0===e?f?b in f?f[b]:f.document.documentElement[d]:a[d]:void(f?f.scrollTo(c?n(f).scrollLeft():e,c?e:n(f).scrollTop()):a[d]=e)},a,d,arguments.length,null)}}),n.each(["top","left"],function(a,b){n.cssHooks[b]=Mb(l.pixelPosition,function(a,c){return c?(c=Kb(a,b),Ib.test(c)?n(a).position()[b]+"px":c):void 0})}),n.each({Height:"height",Width:"width"},function(a,b){n.each({padding:"inner"+a,content:b,"":"outer"+a},function(c,d){n.fn[d]=function(d,e){var f=arguments.length&&(c||"boolean"!=typeof d),g=c||(d===!0||e===!0?"margin":"border");return W(this,function(b,c,d){var e;return n.isWindow(b)?b.document.documentElement["client"+a]:9===b.nodeType?(e=b.documentElement,Math.max(b.body["scroll"+a],e["scroll"+a],b.body["offset"+a],e["offset"+a],e["client"+a])):void 0===d?n.css(b,c,g):n.style(b,c,d,g)},b,f?d:void 0,f,null)}})}),n.fn.size=function(){return this.length},n.fn.andSelf=n.fn.addBack,"function"==typeof define&&define.amd&&define("jquery",[],function(){return n});var fd=a.jQuery,gd=a.$;return n.noConflict=function(b){return a.$===n&&(a.$=gd),b&&a.jQuery===n&&(a.jQuery=fd),n},typeof b===L&&(a.jQuery=a.$=n),n});
5 |
--------------------------------------------------------------------------------
/assets/mailrouter.css:
--------------------------------------------------------------------------------
1 | /*
2 | * Base structure - copied entirely from Bootstrap Admin theme
3 | */
4 |
5 | /* Move down content because we have a fixed navbar that is 50px tall */
6 | body {
7 | padding-top: 50px;
8 | }
9 |
10 |
11 | /*
12 | * Global add-ons
13 | */
14 |
15 | .sub-header {
16 | padding-bottom: 10px;
17 | border-bottom: 1px solid #eee;
18 | }
19 |
20 |
21 | /*
22 | * Main content
23 | */
24 |
25 | .main {
26 | padding: 20px;
27 | }
28 | @media (min-width: 768px) {
29 | .main {
30 | padding-right: 40px;
31 | padding-left: 40px;
32 | }
33 | }
34 | .main .page-header {
35 | margin-top: 0;
36 | }
37 |
38 |
39 | /*
40 | * Placeholder dashboard ideas
41 | */
42 |
43 | .placeholders {
44 | margin-bottom: 30px;
45 | text-align: center;
46 | }
47 | .placeholders h4 {
48 | margin-bottom: 0;
49 | }
50 | .placeholder {
51 | margin-bottom: 20px;
52 | }
53 | .placeholder img {
54 | display: inline-block;
55 | border-radius: 50%;
56 | }
57 |
58 | /* Start of Mailrouter specific CSS */
59 | #filters td, #routes td {
60 | vertical-align: middle;
61 | height: 51px;
62 | }
63 |
64 | #port {
65 | width: 70px
66 | }
67 |
--------------------------------------------------------------------------------
/assets/mailrouter.js:
--------------------------------------------------------------------------------
1 | // Shows or hides the username and password fields when authentication type is changed.
2 | $("#authentication").change(function() {
3 | if ($(this).val() == "crammd5") {
4 | $("#password").attr("placeholder", "secret");
5 | $("#password-label").text("Secret");
6 | $("#password-group").removeClass("hidden").addClass("show");
7 | $("#username-group").removeClass("hidden").addClass("show");
8 | } else if ($(this).val() == "plain") {
9 | $("#password").attr("placeholder", "password");
10 | $("#password-label").text("Password");
11 | $("#password-group").removeClass("hidden").addClass("show");
12 | $("#username-group").removeClass("hidden").addClass("show");
13 | } else {
14 | $("#password-group").removeClass("show").addClass("hidden");
15 | $("#username-group").removeClass("show").addClass("hidden");
16 | }
17 | });
18 |
19 | // Handles "data-method" on links such as:
20 | //
Delete
21 | $('[data-method]').click(function() {
22 | if (confirm($(this).attr('data-confirm'))) {
23 | var form = $('
');
24 | var metadataInput = '
';
25 | form.hide().append(metadataInput).appendTo('body');
26 | form.submit();
27 | }
28 | return false;
29 | });
30 |
31 | // Display tooltips.
32 | $(".status").tooltip({selector: '[data-toggle="tooltip"]', container: "body"})
33 |
--------------------------------------------------------------------------------
/config.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "encoding/json"
5 | "io/ioutil"
6 | "sync"
7 | )
8 |
9 | type Config struct {
10 | sync.RWMutex
11 | Routes map[string]Route
12 | Filters map[string]Filter
13 | Options map[string]string
14 | }
15 |
16 | // Add the DROP route. Make it the default if there is no existing default route.
17 | func AddDropRoute() {
18 | dropIsDefault := true
19 | for _, route := range config.Routes {
20 | if route.IsDefault == true {
21 | dropIsDefault = false
22 | }
23 | }
24 | config.Routes["DROP"] = Route{Id: "DROP", Name: "Drop", IsDefault: dropIsDefault}
25 | }
26 |
27 | func SetDefaultOptions() {
28 | if _, exists := config.Options["PIDFile"]; !exists {
29 | config.Options["PIDFile"] = ""
30 | }
31 | }
32 |
33 | // Load the filter and route configuration from a JSON file.
34 | // Add the drop route as it must always be present.
35 | func LoadConfig() error {
36 | defer func() {
37 | if config.Routes == nil {
38 | config.Routes = map[string]Route{}
39 | }
40 | if config.Filters == nil {
41 | config.Filters = map[string]Filter{}
42 | }
43 | if config.Options == nil {
44 | config.Options = map[string]string{}
45 | }
46 | AddDropRoute()
47 | SetDefaultOptions()
48 | }()
49 |
50 | data, err := ioutil.ReadFile(*confFile)
51 | if err != nil {
52 | return err
53 | }
54 |
55 | err = json.Unmarshal(data, &config)
56 | if err != nil {
57 | return err
58 | }
59 |
60 | return nil
61 | }
62 |
63 | // Create a clone of the global config to use in SaveConfig().
64 | func CloneConfig() *Config {
65 | clone := new(Config)
66 | clone.Routes = map[string]Route{}
67 | clone.Filters = map[string]Filter{}
68 | clone.Options = map[string]string{}
69 | for k, v := range config.Routes {
70 | clone.Routes[k] = v
71 | }
72 | for k, v := range config.Filters {
73 | clone.Filters[k] = v
74 | }
75 | for k, v := range config.Options {
76 | clone.Options[k] = v
77 | }
78 | return clone
79 | }
80 |
81 | // Save the filter and route configuration to a JSON file.
82 | // Remove the DROP route before marshalling - it is a hardcoded route that should never be in the config file.
83 | // Don't delete DROP from the actual config variable (it might be needed mid-save), make a copy instead.
84 | func SaveConfig() error {
85 | clone := CloneConfig()
86 | delete(clone.Routes, "DROP")
87 |
88 | data, err := json.MarshalIndent(clone, "", " ")
89 | if err != nil {
90 | return err
91 | }
92 |
93 | err = ioutil.WriteFile(*confFile, data, 0644)
94 | if err != nil {
95 | return err
96 | }
97 |
98 | return nil
99 | }
100 |
--------------------------------------------------------------------------------
/filter.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "net"
6 | "sort"
7 | "strings"
8 | )
9 |
10 | type Filter struct {
11 | Id string
12 | Order int
13 | Name string
14 | From string
15 | To string
16 | Subject string
17 | Origin string
18 | RouteId string
19 | Summary string // Convenience field for filter listing
20 | RouteName string // Convenience field for filter listing
21 | }
22 |
23 | func (f *Filter) Summarise() string {
24 | var attrs []string
25 | if f.From != "" {
26 | attrs = append(attrs, fmt.Sprintf("From: %s", f.From))
27 | }
28 | if f.To != "" {
29 | attrs = append(attrs, fmt.Sprintf("To: %s", f.To))
30 | }
31 | if f.Subject != "" {
32 | attrs = append(attrs, fmt.Sprintf("Subject: %s", f.Subject))
33 | }
34 | if f.Origin != "" {
35 | attrs = append(attrs, fmt.Sprintf("Origin: %s", f.Origin))
36 | }
37 | return strings.Join(attrs, ", ")
38 | }
39 |
40 | func (f *Filter) Match(from string, to []string, subject string, originIP net.IP) bool {
41 | fieldsSet := 0
42 | if f.From != "" {
43 | fieldsSet++
44 | if !f.MatchFrom(from) {
45 | return false
46 | }
47 | }
48 | if f.To != "" {
49 | fieldsSet++
50 | if !f.MatchTo(to) {
51 | return false
52 | }
53 | }
54 | if f.Subject != "" {
55 | fieldsSet++
56 | if !f.MatchSubject(subject) {
57 | return false
58 | }
59 | }
60 | if f.Origin != "" {
61 | fieldsSet++
62 | if !f.MatchOrigin(originIP) {
63 | return false
64 | }
65 | }
66 | // At this point all the fields that are set have been matched on.
67 | // Return false if none of the relevant fields are set, otherwise return true.
68 | return fieldsSet > 0
69 | }
70 |
71 | func (f *Filter) MatchFrom(from string) bool {
72 | if f.From == "" {
73 | return false
74 | }
75 | return strings.Contains(from, f.From)
76 | }
77 |
78 | func (f *Filter) MatchTo(to []string) bool {
79 | if f.To == "" {
80 | return false
81 | }
82 | // Test against all recipients
83 | for _, address := range to {
84 | if strings.Contains(address, f.To) {
85 | return true
86 | }
87 | }
88 | return false
89 | }
90 |
91 | func (f *Filter) MatchSubject(subject string) bool {
92 | if f.Subject == "" {
93 | return false
94 | }
95 | return strings.Contains(subject, f.Subject)
96 | }
97 |
98 | func (f *Filter) MatchOrigin(originIP net.IP) bool {
99 | // Is filter.Origin in CIDR notation e.g. "192.168.100.1/24" or "2001:DB8::/48"?
100 | _, filterNet, err := net.ParseCIDR(f.Origin)
101 | if err == nil {
102 | return filterNet.Contains(originIP)
103 | }
104 |
105 | // Is filter.Origin a valid IPv4 or IPv6 address?
106 | filterIP := net.ParseIP(f.Origin)
107 | if filterIP != nil {
108 | return filterIP.Equal(originIP)
109 | }
110 |
111 | return false
112 | }
113 |
114 | type FilterList []Filter
115 |
116 | // Implement sort.Iterface
117 | func (fl FilterList) Len() int {
118 | return len(fl)
119 | }
120 |
121 | func (fl FilterList) Swap(i, j int) {
122 | fl[i], fl[j] = fl[j], fl[i]
123 | }
124 |
125 | func (fl FilterList) Less(i, j int) bool {
126 | return fl[i].Order < fl[j].Order
127 | }
128 |
129 | func SortedFilters() FilterList {
130 | fl := make(FilterList, len(config.Filters))
131 | i := 0
132 | for _, filter := range config.Filters {
133 | fl[i] = filter
134 | i++
135 | }
136 | sort.Sort(fl)
137 | return fl
138 | }
139 |
--------------------------------------------------------------------------------
/filter_test.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "net"
6 | "testing"
7 | )
8 |
9 | // Test for valid generation of summary string.
10 | // Only populated fields should be returned.
11 | func TestFilterSummarise(t *testing.T) {
12 | f := Filter{}
13 | summary := ""
14 | if output := f.Summarise(); output != summary {
15 | t.Errorf("filter.Summarise() = %s, want %s", output, summary)
16 | }
17 | f.From = "sender@example.com"
18 | summary = fmt.Sprintf("From: %s", f.From)
19 | if output := f.Summarise(); output != summary {
20 | t.Errorf("filter.Summarise() = %s, want %s", output, summary)
21 | }
22 | f.To = "recipient@example.com"
23 | summary = fmt.Sprintf("From: %s, To: %s", f.From, f.To)
24 | if output := f.Summarise(); output != summary {
25 | t.Errorf("filter.Summarise() = %s, want %s", output, summary)
26 | }
27 | f.Subject = "Lorem ipsum dolor sit amet"
28 | summary = fmt.Sprintf("From: %s, To: %s, Subject: %s", f.From, f.To, f.Subject)
29 | if output := f.Summarise(); output != summary {
30 | t.Errorf("filter.Summarise() = %s, want %s", output, summary)
31 | }
32 | f.Origin = "127.0.0.1"
33 | summary = fmt.Sprintf("From: %s, To: %s, Subject: %s, Origin: %s", f.From, f.To, f.Subject, f.Origin)
34 | if output := f.Summarise(); output != summary {
35 | t.Errorf("filter.Summarise() = %s, want %s", output, summary)
36 | }
37 | }
38 |
39 | func TestFilterMatch(t *testing.T) {
40 | tests := []struct {
41 | f Filter
42 | out bool
43 | }{
44 | {Filter{}, false},
45 | // Full field positive matches
46 | {Filter{From: "sender@example.com"}, true},
47 | {Filter{From: "sender@example.com", To: "recipient@example.com"}, true},
48 | {Filter{From: "sender@example.com", To: "recipient@example.com", Subject: "Lorem ipsum dolor sit amet"}, true},
49 | {Filter{From: "sender@example.com", To: "recipient@example.com", Subject: "Lorem ipsum dolor sit amet", Origin: "127.0.0.1"}, true},
50 | {Filter{To: "recipient@example.com"}, true},
51 | {Filter{To: "recipient@example.com", Subject: "Lorem ipsum dolor sit amet"}, true},
52 | {Filter{To: "recipient@example.com", Subject: "Lorem ipsum dolor sit amet", Origin: "127.0.0.1"}, true},
53 | {Filter{Subject: "Lorem ipsum dolor sit amet"}, true},
54 | {Filter{Subject: "Lorem ipsum dolor sit amet", Origin: "127.0.0.1"}, true},
55 | {Filter{Origin: "127.0.0.1"}, true},
56 | // Full field negative matches
57 | {Filter{From: "sender2@example.com"}, false},
58 | {Filter{From: "sender@example.com", To: "recipient2@example.com"}, false},
59 | {Filter{From: "sender@example.com", To: "recipient@example.com", Subject: "Lorem ipsum dolor sit amet 2"}, false},
60 | {Filter{From: "sender@example.com", To: "recipient@example.com", Subject: "Lorem ipsum dolor sit amet", Origin: "127.0.0.2"}, false},
61 | // Partial field positive matches
62 | {Filter{From: "sender", To: "recipient@example.com", Subject: "Lorem ipsum dolor sit amet"}, true},
63 | {Filter{From: "sender@example.com", To: "recipient", Subject: "Lorem ipsum dolor sit amet"}, true},
64 | {Filter{From: "sender@example.com", To: "recipient@example.com", Subject: "Lorem ipsum"}, true},
65 | // Partial field negative matches
66 | {Filter{From: "sender2", To: "recipient@example.com", Subject: "Lorem ipsum dolor sit amet"}, false},
67 | {Filter{From: "sender@example.com", To: "recipient2", Subject: "Lorem ipsum dolor sit amet"}, false},
68 | {Filter{From: "sender@example.com", To: "recipient@example.com", Subject: "lorem ipsum"}, false},
69 | }
70 | from := "sender@example.com"
71 | to := []string{"recipient@example.com"}
72 | subject := "Lorem ipsum dolor sit amet"
73 | originIP := net.ParseIP("127.0.0.1")
74 | for _, tt := range tests {
75 | if x := tt.f.Match(from, to, subject, originIP); x != tt.out {
76 | t.Errorf("Filter{%v}.Match(%v, %v, %v, %v) = %v, want %v", tt.f, from, to, subject, originIP, x, tt.out)
77 | }
78 | }
79 | }
80 |
81 | func TestFilterMatchFrom(t *testing.T) {
82 | tests := []struct {
83 | from string
84 | out bool
85 | }{
86 | {"", false},
87 | {"sender", true},
88 | {"example.com", true},
89 | {"example.org", false},
90 | {"sender@example.com", true},
91 | }
92 | f := Filter{}
93 | from := "sender@example.com"
94 | for _, tt := range tests {
95 | f.From = tt.from
96 | if x := f.MatchFrom(from); x != tt.out {
97 | t.Errorf("Filter{From: %s}.MatchFrom(%s) = %v, want %v", tt.from, from, x, tt.out)
98 | }
99 | }
100 | }
101 |
102 | func TestFilterMatchTo(t *testing.T) {
103 | tests := []struct {
104 | to string
105 | out bool
106 | }{
107 | {"", false},
108 | {"recipient", true},
109 | {"recipient3", false},
110 | {"example.com", true},
111 | {"example.org", false},
112 | {"recipient@example.com", true},
113 | {"recipient2@example.net", true},
114 | }
115 | f := Filter{}
116 | to := []string{"recipient@example.com", "recipient2@example.net"}
117 | for _, tt := range tests {
118 | f.To = tt.to
119 | if x := f.MatchTo(to); x != tt.out {
120 | t.Errorf("Filter{To: %s}.MatchTo(%v) = %v, want %v", tt.to, to, x, tt.out)
121 | }
122 | }
123 | }
124 |
125 | func TestFilterMatchSubject(t *testing.T) {
126 | tests := []struct {
127 | subject string
128 | out bool
129 | }{
130 | {"", false},
131 | {"Lorem", true},
132 | {"lorem", false}, // Case sensitivity is desired
133 | {"Lorum", false},
134 | {"Lorem ipsum dolor sit amet", true},
135 | }
136 | f := Filter{}
137 | subject := "Lorem ipsum dolor sit amet"
138 | for _, tt := range tests {
139 | f.Subject = tt.subject
140 | if x := f.MatchSubject(subject); x != tt.out {
141 | t.Errorf("Filter{Subject: %s}.MatchSubject(%s) = %v, want %v", tt.subject, subject, x, tt.out)
142 | }
143 | }
144 | }
145 |
146 | func TestFilterMatchOrigin(t *testing.T) {
147 | tests := []struct {
148 | origin string
149 | out bool
150 | }{
151 | {"", false},
152 | {"127.0.0.1", true},
153 | {"127.0.0.2", false},
154 | {"10.0.0.1/24", false},
155 | {"127.0.0.1/24", true},
156 | {"127.0.0.1/32", true},
157 | }
158 | f := Filter{}
159 | addr := net.ParseIP("127.0.0.1")
160 | for _, tt := range tests {
161 | f.Origin = tt.origin
162 | if x := f.MatchOrigin(addr); x != tt.out {
163 | t.Errorf("Filter{Origin: %s}.MatchOrigin(%s) = %v, want %v", tt.origin, "127.0.0.1", x, tt.out)
164 | }
165 | }
166 | }
167 |
--------------------------------------------------------------------------------
/helpers.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "encoding/base64"
5 | "net/http"
6 | "strings"
7 | )
8 |
9 | // Utility function for parsing URLs like /route/:id/:action
10 | func ParsePath(path string) (base string, id string, action string) {
11 | path = strings.Trim(path, "/")
12 | parts := strings.Split(path, "/")
13 | if len(parts) > 0 {
14 | base = parts[0]
15 | }
16 | if len(parts) > 1 {
17 | id = parts[1]
18 | }
19 | if len(parts) > 2 {
20 | action = parts[2]
21 | }
22 | return base, id, action
23 | }
24 |
25 | // Wrapper function for setting flash messages via cookies.
26 | func SetCookie(w http.ResponseWriter, name string, value string) {
27 | valueEnc := base64.StdEncoding.EncodeToString([]byte(value))
28 | http.SetCookie(w, &http.Cookie{Name: name, Value: valueEnc, Path: "/"})
29 | }
30 |
31 | // Wrapper function for getting flash messages via cookies.
32 | func GetCookie(w http.ResponseWriter, req *http.Request, name string) string {
33 | value := ""
34 | cookie, _ := req.Cookie(name)
35 | if cookie != nil {
36 | valueDec, err := base64.StdEncoding.DecodeString(cookie.Value)
37 | if err == nil {
38 | value = string(valueDec)
39 | }
40 | // The cookie has been read, so clear it by setting MaxAge < 0
41 | http.SetCookie(w, &http.Cookie{Name: name, MaxAge: -1, Path: "/"})
42 | }
43 | return value
44 | }
45 |
--------------------------------------------------------------------------------
/logs.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "net"
5 | "strings"
6 | "sync"
7 | "time"
8 | )
9 |
10 | const MaxLogs = 20
11 |
12 | type Log struct {
13 | Received string
14 | From string
15 | To string
16 | Subject string
17 | Filter string
18 | Route string
19 | Status string
20 | Error string
21 | }
22 |
23 | type LogList struct {
24 | sync.RWMutex
25 | Logs []Log
26 | }
27 |
28 | func (ll *LogList) Add(origin net.IP, from string, to []string, subject string, filter string, route string, status string, error string) {
29 | ll.Lock()
30 | defer ll.Unlock()
31 |
32 | l := Log{
33 | Received: time.Now().Format("2006-01-02 15:04:05"),
34 | From: from,
35 | To: strings.Join(to, ", "),
36 | Subject: subject,
37 | Filter: filter,
38 | Route: route,
39 | Status: status,
40 | Error: error,
41 | }
42 |
43 | // Expand the log list out to the max size.
44 | if len(ll.Logs) < MaxLogs {
45 | ll.Logs = append(ll.Logs, Log{})
46 | }
47 |
48 | // Shuffle the existing log entries down one slot and put the new one in the first slot.
49 | copy(ll.Logs[1:], ll.Logs[0:])
50 | ll.Logs[0] = l
51 | }
52 |
--------------------------------------------------------------------------------
/logs_test.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "net"
6 | "testing"
7 | )
8 |
9 | func TestLogListAdd(t *testing.T) {
10 | logs := LogList{}
11 | ip := net.ParseIP("127.0.0.1")
12 | to := []string{"To"}
13 |
14 | // Test that log list grows to MaxLogs in size.
15 | for i := 1; i <= MaxLogs; i++ {
16 | logs.Add(ip, "From", to, fmt.Sprintf("%d", i), "Filter", "Route", "Status", "Error")
17 | if len(logs.Logs) != i {
18 | t.Errorf("LogList contains %v entries, want %v", len(logs.Logs), i)
19 | }
20 | }
21 |
22 | // Test that the most recently added log is in the first element.
23 | if logs.Logs[0].Subject != fmt.Sprintf("%d", MaxLogs) {
24 | t.Errorf("LogList newest entry subject is %v, want %v", logs.Logs[0].Subject, MaxLogs)
25 | }
26 |
27 | // Test that log list grows no further than MaxLogs in size.
28 | for i := 1; i < MaxLogs; i++ {
29 | logs.Add(ip, "From", to, fmt.Sprintf("%d", i), "Filter", "Route", "Status", "Error")
30 | if len(logs.Logs) != MaxLogs {
31 | t.Errorf("LogList contains %v entries, want %v", len(logs.Logs), MaxLogs)
32 | }
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/mailrouter.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "bytes"
5 | "flag"
6 | "fmt"
7 | "html/template"
8 | "log"
9 | "mime"
10 | "net"
11 | "net/http"
12 | "net/mail"
13 | "net/smtp"
14 | "path/filepath"
15 | "strconv"
16 | "time"
17 |
18 | "github.com/mhale/smtpd"
19 | "github.com/streadway/simpleuuid"
20 | )
21 |
22 | var (
23 | config Config // Filters & routes
24 | stats Stats // Statistics of sent and dropped mail
25 | logs LogList // Recent mail log for Dashboard
26 | )
27 |
28 | var httpAddr *string = flag.String("http", ":8080", "Address & port for HTTP server")
29 | var smtpAddr *string = flag.String("smtp", ":2525", "Address & port for SMTP server")
30 | var confFile *string = flag.String("conf", "/etc/mailrouter.conf", "Full path to configuration file")
31 |
32 | // Handler for handling incoming mail messages.
33 | func mailHandler(origin net.Addr, from string, to []string, data []byte) {
34 | originIPStr, _, _ := net.SplitHostPort(origin.String())
35 | originIP := net.ParseIP(originIPStr)
36 |
37 | // Parse the message to get the Subject header.
38 | msg, err := mail.ReadMessage(bytes.NewReader(data))
39 | if err != nil {
40 | log.Printf("Failed to parse message: %s\n", err)
41 | log.Printf("Aborting processing of message from %s.", from)
42 | return
43 | }
44 | subject := msg.Header.Get("Subject")
45 |
46 | // Check each filter in order.
47 | var filterName string
48 | var routeId string
49 | for _, filter := range SortedFilters() {
50 | if filter.Match(from, to, subject, originIP) {
51 | filterName = filter.Name
52 | routeId = filter.RouteId
53 | break
54 | }
55 | }
56 |
57 | // Use the default route if no filters were matched.
58 | if routeId == "" {
59 | for _, route := range config.Routes {
60 | if route.IsDefault {
61 | routeId = route.Id
62 | }
63 | }
64 | }
65 |
66 | // If the message is to be dropped, record the drop and return.
67 | if routeId == "DROP" {
68 | stats.Dropped(len(data))
69 | logs.Add(originIP, from, to, subject, filterName, "Drop", "", "")
70 | return
71 | }
72 |
73 | // Otherwise, deliver the mail to the selected route and record the delivery.
74 | route := config.Routes[routeId]
75 | addr := route.Hostname + ":" + strconv.Itoa(route.Port)
76 |
77 | // Override the recipient if To field is set.
78 | if route.To != "" {
79 | to = []string{route.To}
80 | }
81 |
82 | // Deliver the mail.
83 | var auth smtp.Auth
84 | auth = nil
85 | if route.AuthType == "plain" {
86 | auth = smtp.PlainAuth("", route.Username, route.Password, route.Hostname)
87 | } else if route.AuthType == "crammd5" {
88 | auth = smtp.CRAMMD5Auth(route.Username, route.Password)
89 | }
90 |
91 | err = smtp.SendMail(addr, auth, from, to, data)
92 | if err != nil {
93 | msg := fmt.Sprintf("Failed to deliver mail to route %s (%s): %s", route.Name, addr, err)
94 | log.Printf(msg)
95 | stats.Failed(len(data))
96 | logs.Add(originIP, from, to, subject, filterName, route.Name, "Failed", msg)
97 | } else {
98 | stats.Sent(len(data))
99 | logs.Add(originIP, from, to, subject, filterName, route.Name, "Sent", "")
100 | }
101 | }
102 |
103 | func routeHandler(w http.ResponseWriter, req *http.Request) {
104 | var msg string
105 | _, id, action := ParsePath(req.URL.Path)
106 | method := req.FormValue("_method")
107 |
108 | if req.Method == "GET" {
109 | data := make(map[string]interface{})
110 | data["list"] = SortedRoutes()
111 |
112 | // Populate the form if requested.
113 | if id != "" && action == "edit" {
114 | data["id"] = id
115 | data["edit"] = config.Routes[id]
116 | }
117 |
118 | // Check for info and error messages passed via cookies. Clear any that are displayed.
119 | msg = GetCookie(w, req, "info")
120 | if msg != "" {
121 | data["info"] = msg
122 | }
123 | msg = GetCookie(w, req, "error")
124 | if msg != "" {
125 | data["error"] = msg
126 | }
127 |
128 | // Render the page. Reparsing the template every time eases development at the expense of performance.
129 | html, _ := Asset("views/routes.html")
130 | tmpl, err := template.New("routes").Parse(string(html))
131 | if err != nil {
132 | log.Println(err)
133 | }
134 | err = tmpl.Execute(w, data)
135 | if err != nil {
136 | log.Println(err)
137 | }
138 | }
139 |
140 | if req.Method == "POST" {
141 | config.Lock()
142 | defer config.Unlock()
143 |
144 | if method == "delete" {
145 | msg = fmt.Sprintf("Deleted route %s.", config.Routes[id].Name)
146 | // If a default route was deleted, make Drop the default.
147 | if config.Routes[id].IsDefault == true {
148 | route := config.Routes["DROP"]
149 | route.IsDefault = true
150 | config.Routes["DROP"] = route
151 | msg = fmt.Sprintf("%s The Drop route is now the default route.", msg)
152 | }
153 | delete(config.Routes, id)
154 | }
155 |
156 | if method == "default" {
157 | msg = fmt.Sprintf("The %s route is now the default route.", config.Routes[id].Name)
158 | for id, route := range config.Routes {
159 | route.IsDefault = false
160 | config.Routes[id] = route
161 | }
162 | route := config.Routes[id]
163 | route.IsDefault = true
164 | config.Routes[id] = route
165 | }
166 |
167 | if method == "save" {
168 | // Unset id means a new route is being added
169 | if id == "" {
170 | msg = fmt.Sprintf("Added route %s to host %s.", req.FormValue("routename"), req.FormValue("hostname"))
171 | uuid, _ := simpleuuid.NewTime(time.Now())
172 | id = uuid.String()
173 | } else {
174 | msg = fmt.Sprintf("Updated route %s.", req.FormValue("routename"))
175 | }
176 |
177 | // Create a new Route from the form submission.
178 | port, _ := strconv.Atoi(req.FormValue("port"))
179 | isDefault, _ := strconv.ParseBool(req.FormValue("isdefault"))
180 | route := Route{
181 | Id: id,
182 | Name: req.FormValue("routename"),
183 | To: req.FormValue("to"),
184 | Hostname: req.FormValue("hostname"),
185 | Port: port,
186 | AuthType: req.FormValue("authentication"),
187 | Username: req.FormValue("username"),
188 | Password: req.FormValue("password"),
189 | IsDefault: isDefault,
190 | }
191 | config.Routes[id] = route
192 | }
193 |
194 | if msg != "" {
195 | log.Printf(msg)
196 | SetCookie(w, "info", msg)
197 | err := SaveConfig()
198 | if err != nil {
199 | msg = fmt.Sprintf("Failed to save configuration to file: %v", err)
200 | log.Printf(msg)
201 | SetCookie(w, "error", msg)
202 | }
203 | }
204 |
205 | http.Redirect(w, req, "/routes/", http.StatusFound)
206 | }
207 | }
208 |
209 | func filterHandler(w http.ResponseWriter, req *http.Request) {
210 | var msg string
211 | _, id, action := ParsePath(req.URL.Path)
212 | method := req.FormValue("_method")
213 |
214 | if req.Method == "GET" {
215 | data := make(map[string]interface{})
216 | data["list"] = SortedFilters()
217 | data["routes"] = SortedRoutes()
218 |
219 | if len(config.Routes) == 1 {
220 | data["info"] = "No routes are defined. It is recommended to define routes before filters to populate the route drop-down menu below."
221 | }
222 |
223 | // Populate the form if requested.
224 | if id != "" && action == "edit" {
225 | data["id"] = id
226 | data["edit"] = config.Filters[id]
227 | }
228 |
229 | // Check for info and error messages passed via cookies. Clear any that are displayed.
230 | msg = GetCookie(w, req, "info")
231 | if msg != "" {
232 | data["info"] = msg
233 | }
234 | msg = GetCookie(w, req, "error")
235 | if msg != "" {
236 | data["error"] = msg
237 | }
238 |
239 | // Render the page. Reparsing the template every time eases development at the expense of performance.
240 | html, _ := Asset("views/filters.html")
241 | tmpl, err := template.New("filters").Parse(string(html))
242 | if err != nil {
243 | log.Println(err)
244 | }
245 | err = tmpl.Execute(w, data)
246 | if err != nil {
247 | log.Println(err)
248 | }
249 | }
250 |
251 | if req.Method == "POST" {
252 | config.Lock()
253 | defer config.Unlock()
254 |
255 | if method == "delete" {
256 | msg = fmt.Sprintf("Deleted filter %s.", config.Filters[id].Name)
257 | delete(config.Filters, id)
258 | }
259 |
260 | if method == "save" {
261 | // Unset id means a new filter is being added
262 | if id == "" {
263 | msg = fmt.Sprintf("Added filter %s.", req.FormValue("filtername"))
264 | uuid, _ := simpleuuid.NewTime(time.Now())
265 | id = uuid.String()
266 | } else {
267 | msg = fmt.Sprintf("Updated filter %s.", req.FormValue("filtername"))
268 | }
269 |
270 | // Create a new Filter from the form submission.
271 | order, _ := strconv.Atoi(req.FormValue("order"))
272 | filter := Filter{
273 | Id: id,
274 | Order: order,
275 | Name: req.FormValue("filtername"),
276 | To: req.FormValue("to"),
277 | From: req.FormValue("from"),
278 | Origin: req.FormValue("origin"),
279 | Subject: req.FormValue("subject"),
280 | RouteId: req.FormValue("route-id"),
281 | }
282 | filter.Summary = filter.Summarise()
283 | filter.RouteName = config.Routes[filter.RouteId].Name
284 | config.Filters[id] = filter
285 | }
286 |
287 | if msg != "" {
288 | log.Printf(msg)
289 | SetCookie(w, "info", msg)
290 | err := SaveConfig()
291 | if err != nil {
292 | msg = fmt.Sprintf("Failed to save configuration to file: %v", err)
293 | log.Printf(msg)
294 | SetCookie(w, "error", msg)
295 | }
296 | }
297 |
298 | http.Redirect(w, req, "/filters/", http.StatusFound)
299 | }
300 | }
301 |
302 | // Handler for serving the Dashboard.
303 | func indexHandler(w http.ResponseWriter, req *http.Request) {
304 | // Catch bad URLs.
305 | if req.URL.Path != "/" {
306 | http.NotFound(w, req)
307 | return
308 | }
309 |
310 | data := make(map[string]interface{})
311 | data["stats"] = stats
312 | data["logs"] = logs.Logs
313 | data["maxLogs"] = MaxLogs
314 |
315 | html, _ := Asset("views/index.html")
316 | tmpl, err := template.New("index").Parse(string(html))
317 | if err != nil {
318 | log.Println(err)
319 | }
320 | err = tmpl.Execute(w, data)
321 | if err != nil {
322 | log.Println(err)
323 | }
324 | }
325 |
326 | // Handler for serving static assets (CSS/JS).
327 | func assetHandler(w http.ResponseWriter, req *http.Request) {
328 | path := string(req.URL.Path[1:]) // Strip leading slash
329 | data, err := Asset(path)
330 | if err != nil {
331 | log.Printf("Asset not found: %s", path)
332 | http.NotFound(w, req)
333 | return
334 | }
335 |
336 | ext := filepath.Ext(path)
337 | contentType := mime.TypeByExtension(ext)
338 | w.Header().Set("Content-Type", contentType)
339 | w.Write(data)
340 | }
341 |
342 | func main() {
343 | flag.Parse()
344 |
345 | // Load filters & routes from configuration file.
346 | err := LoadConfig()
347 | if err != nil {
348 | log.Printf("Could not load configuration file: %s", err)
349 | log.Printf("No routes or filters are defined. All incoming mail will be dropped.")
350 | } else {
351 | // Subtract 1 from config.Routes to account for Drop route.
352 | log.Printf("Loaded %d routes and %d filters.", len(config.Routes)-1, len(config.Filters))
353 | }
354 |
355 | // Create a PID file.
356 | if config.Options["PIDFile"] != "" {
357 | err := CreatePIDFile()
358 | if err != nil {
359 | log.Printf("Could not create PID file: %s", err)
360 | } else {
361 | defer RemovePIDFile()
362 | }
363 | }
364 |
365 | // Run HTTP server in the background.
366 | log.Printf("Mailrouter serving HTTP on %s", *httpAddr)
367 | http.HandleFunc("/", indexHandler)
368 | http.HandleFunc("/assets/", assetHandler)
369 | http.HandleFunc("/routes/", routeHandler)
370 | http.HandleFunc("/filters/", filterHandler)
371 | go http.ListenAndServe(*httpAddr, nil)
372 |
373 | // Run SMTP server in the foreground to force an exit if it fails.
374 | log.Printf("Mailrouter serving SMTP on %s", *smtpAddr)
375 | err = smtpd.ListenAndServe(*smtpAddr, mailHandler, "Mailrouter", "")
376 | if err != nil {
377 | log.Printf("smtpd.ListenAndServe error: %v", err)
378 | }
379 |
380 | log.Println("Exiting.")
381 | }
382 |
--------------------------------------------------------------------------------
/pidfile.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "io/ioutil"
5 | "os"
6 | "strconv"
7 | )
8 |
9 | func CreatePIDFile() error {
10 | pid := os.Getpid()
11 | data := []byte(strconv.Itoa(pid))
12 | err := ioutil.WriteFile(config.Options["PIDFile"], data, 0644)
13 | if err != nil {
14 | return err
15 | }
16 |
17 | return nil
18 | }
19 |
20 | func RemovePIDFile() error {
21 | err := os.Remove(config.Options["PIDFile"])
22 | if err != nil && !os.IsNotExist(err) {
23 | return err
24 | }
25 |
26 | return nil
27 | }
28 |
--------------------------------------------------------------------------------
/route.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "sort"
5 | )
6 |
7 | type Route struct {
8 | Id string
9 | Name string
10 | To string
11 | Hostname string
12 | Port int
13 | AuthType string
14 | Username string
15 | Password string
16 | IsDefault bool
17 | }
18 |
19 | type RouteList []Route
20 |
21 | // Implement sort.Interface
22 | func (rl RouteList) Len() int {
23 | return len(rl)
24 | }
25 |
26 | func (rl RouteList) Swap(i, j int) {
27 | rl[i], rl[j] = rl[j], rl[i]
28 | }
29 |
30 | // Ensure the DROP route is last.
31 | func (rl RouteList) Less(i, j int) bool {
32 | if rl[i].Id == "DROP" {
33 | return false
34 | }
35 | if rl[j].Id == "DROP" {
36 | return true
37 | }
38 | return rl[i].Name < rl[j].Name
39 | }
40 |
41 | func SortedRoutes() RouteList {
42 | rl := make(RouteList, len(config.Routes))
43 | i := 0
44 | for _, route := range config.Routes {
45 | rl[i] = route
46 | i++
47 | }
48 | sort.Sort(rl)
49 | return rl
50 | }
51 |
--------------------------------------------------------------------------------
/stats.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "sync"
5 | )
6 |
7 | type Stats struct {
8 | sync.RWMutex
9 | MsgsSent int
10 | MsgsDropped int
11 | MsgsFailed int
12 | DataSent int
13 | DataDropped int
14 | DataFailed int
15 | }
16 |
17 | func (s *Stats) Sent(size int) {
18 | s.Lock()
19 | defer s.Unlock()
20 | s.MsgsSent++
21 | s.DataSent += size
22 | }
23 |
24 | func (s *Stats) Dropped(size int) {
25 | s.Lock()
26 | defer s.Unlock()
27 | s.MsgsDropped++
28 | s.DataDropped += size
29 | }
30 |
31 | func (s *Stats) Failed(size int) {
32 | s.Lock()
33 | defer s.Unlock()
34 | s.MsgsFailed++
35 | s.DataFailed += size
36 | }
37 |
--------------------------------------------------------------------------------
/stats_test.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "math/rand"
5 | "testing"
6 | "time"
7 | )
8 |
9 | // Record the sending of two randomly sized emails and verify that the
10 | // sent stats show 2 emails and the combined data size.
11 | func TestStatsSent(t *testing.T) {
12 | var r = rand.New(rand.NewSource(time.Now().UTC().UnixNano()))
13 | var n1 = r.Intn(100)
14 | var n2 = r.Intn(100)
15 |
16 | stats := Stats{}
17 | stats.Sent(n1)
18 | if stats.MsgsSent != 1 {
19 | t.Errorf("stats.MsgsSent = %v, want %v", stats.MsgsSent, 1)
20 | }
21 | if stats.MsgsDropped != 0 {
22 | t.Errorf("stats.MsgsDropped = %v, want %v", stats.MsgsDropped, 0)
23 | }
24 | if stats.MsgsFailed != 0 {
25 | t.Errorf("stats.MsgsFailed = %v, want %v", stats.MsgsFailed, 0)
26 | }
27 | if stats.DataSent != n1 {
28 | t.Errorf("stats.DataSent = %v, want %v", stats.DataSent, n1)
29 | }
30 | if stats.DataDropped != 0 {
31 | t.Errorf("stats.DataDropped = %v, want %v", stats.DataDropped, 0)
32 | }
33 | if stats.DataFailed != 0 {
34 | t.Errorf("stats.DataFailed = %v, want %v", stats.DataFailed, 0)
35 | }
36 |
37 | stats.Sent(n2)
38 | if stats.MsgsSent != 2 {
39 | t.Errorf("stats.MsgsSent = %v, want %v", stats.MsgsSent, 2)
40 | }
41 | if stats.MsgsDropped != 0 {
42 | t.Errorf("stats.MsgsDropped = %v, want %v", stats.MsgsDropped, 0)
43 | }
44 | if stats.MsgsFailed != 0 {
45 | t.Errorf("stats.MsgsFailed = %v, want %v", stats.MsgsFailed, 0)
46 | }
47 | if stats.DataSent != n1+n2 {
48 | t.Errorf("stats.DataSent = %v, want %v", stats.DataSent, n1+n2)
49 | }
50 | if stats.DataDropped != 0 {
51 | t.Errorf("stats.DataDropped = %v, want %v", stats.DataDropped, 0)
52 | }
53 | if stats.DataFailed != 0 {
54 | t.Errorf("stats.DataFailed = %v, want %v", stats.DataFailed, 0)
55 | }
56 | }
57 |
58 | // Record the dropping of two randomly sized emails and verify that the
59 | // dropped stats show 2 emails and the combined data size.
60 | func TestStatsDropped(t *testing.T) {
61 | var r = rand.New(rand.NewSource(time.Now().UTC().UnixNano()))
62 | var n1 = r.Intn(100)
63 | var n2 = r.Intn(100)
64 |
65 | stats := Stats{}
66 | stats.Dropped(n1)
67 | if stats.MsgsSent != 0 {
68 | t.Errorf("stats.MsgsSent = %v, want %v", stats.MsgsSent, 0)
69 | }
70 | if stats.MsgsDropped != 1 {
71 | t.Errorf("stats.MsgsDropped = %v, want %v", stats.MsgsDropped, 1)
72 | }
73 | if stats.MsgsFailed != 0 {
74 | t.Errorf("stats.MsgsFailed = %v, want %v", stats.MsgsFailed, 0)
75 | }
76 | if stats.DataSent != 0 {
77 | t.Errorf("stats.DataSent = %v, want %v", stats.DataSent, 0)
78 | }
79 | if stats.DataDropped != n1 {
80 | t.Errorf("stats.DataDropped = %v, want %v", stats.DataDropped, n1)
81 | }
82 | if stats.DataFailed != 0 {
83 | t.Errorf("stats.DataFailed = %v, want %v", stats.DataFailed, 0)
84 | }
85 |
86 | stats.Dropped(n2)
87 | if stats.MsgsSent != 0 {
88 | t.Errorf("stats.MsgsSent = %v, want %v", stats.MsgsSent, 0)
89 | }
90 | if stats.MsgsDropped != 2 {
91 | t.Errorf("stats.MsgsDropped = %v, want %v", stats.MsgsDropped, 2)
92 | }
93 | if stats.MsgsFailed != 0 {
94 | t.Errorf("stats.MsgsFailed = %v, want %v", stats.MsgsFailed, 0)
95 | }
96 | if stats.DataSent != 0 {
97 | t.Errorf("stats.DataSent = %v, want %v", stats.DataSent, 0)
98 | }
99 | if stats.DataDropped != n1+n2 {
100 | t.Errorf("stats.DataDropped = %v, want %v", stats.DataDropped, n1+n2)
101 | }
102 | if stats.DataFailed != 0 {
103 | t.Errorf("stats.DataFailed = %v, want %v", stats.DataFailed, 0)
104 | }
105 | }
106 |
107 | // Record the failed delivery of two randomly sized emails and verify that the
108 | // failed stats show 2 emails and the combined data size.
109 | func TestStatsFailed(t *testing.T) {
110 | var r = rand.New(rand.NewSource(time.Now().UTC().UnixNano()))
111 | var n1 = r.Intn(100)
112 | var n2 = r.Intn(100)
113 |
114 | stats := Stats{}
115 | stats.Failed(n1)
116 | if stats.MsgsSent != 0 {
117 | t.Errorf("stats.MsgsSent = %v, want %v", stats.MsgsSent, 0)
118 | }
119 | if stats.MsgsDropped != 0 {
120 | t.Errorf("stats.MsgsDropped = %v, want %v", stats.MsgsDropped, 0)
121 | }
122 | if stats.MsgsFailed != 1 {
123 | t.Errorf("stats.MsgsFailed = %v, want %v", stats.MsgsFailed, 1)
124 | }
125 | if stats.DataSent != 0 {
126 | t.Errorf("stats.DataSent = %v, want %v", stats.DataSent, 0)
127 | }
128 | if stats.DataDropped != 0 {
129 | t.Errorf("stats.DataDropped = %v, want %v", stats.DataDropped, 0)
130 | }
131 | if stats.DataFailed != n1 {
132 | t.Errorf("stats.DataFailed = %v, want %v", stats.DataFailed, n1)
133 | }
134 |
135 | stats.Failed(n2)
136 | if stats.MsgsSent != 0 {
137 | t.Errorf("stats.MsgsSent = %v, want %v", stats.MsgsSent, 0)
138 | }
139 | if stats.MsgsDropped != 0 {
140 | t.Errorf("stats.MsgsDropped = %v, want %v", stats.MsgsDropped, 0)
141 | }
142 | if stats.MsgsFailed != 2 {
143 | t.Errorf("stats.MsgsFailed = %v, want %v", stats.MsgsFailed, 2)
144 | }
145 | if stats.DataSent != 0 {
146 | t.Errorf("stats.DataSent = %v, want %v", stats.DataSent, 0)
147 | }
148 | if stats.DataDropped != 0 {
149 | t.Errorf("stats.DataDropped = %v, want %v", stats.DataDropped, 0)
150 | }
151 | if stats.DataFailed != n1+n2 {
152 | t.Errorf("stats.DataFailed = %v, want %v", stats.DataFailed, n1+n2)
153 | }
154 | }
155 |
--------------------------------------------------------------------------------
/views/filters.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
Mailrouter
11 |
12 |
13 |
14 |
18 |
19 |
20 |
21 |
41 |
42 |
43 |
44 |
45 |
46 | {{if .info}}
{{.info}}
{{end}}
47 | {{if .error}}
{{.error}}
{{end}}
48 |
123 |
124 |
125 |
126 |
127 | Order |
128 | Name |
129 | Match On |
130 | Route |
131 | |
132 |
133 |
134 |
135 |
136 | |
137 | |
138 | |
139 | |
140 | |
141 |
142 |
143 |
144 | {{range $index, $filter := .list}}
145 |
146 | {{$filter.Order}} |
147 | {{$filter.Name}} |
148 | {{$filter.Summary}} |
149 | {{$filter.RouteName}} |
150 |
151 | Edit
152 | Delete
153 | |
154 |
155 | {{end}}
156 |
157 |
158 |
159 |
160 |
161 |
162 |
163 |
164 |
165 |
166 |
167 |
168 |
169 |
--------------------------------------------------------------------------------
/views/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
Mailrouter
11 |
12 |
13 |
14 |
18 |
19 |
20 |
21 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
{{.stats.MsgsSent}}
50 | Messages sent
51 |
52 |
53 |
{{.stats.MsgsDropped}}
54 | Messages dropped
55 |
56 |
57 |
{{.stats.MsgsFailed}}
58 | Messages failed
59 |
60 |
61 |
{{.stats.DataSent}} bytes
62 | Data sent
63 |
64 |
65 |
{{.stats.DataDropped}} bytes
66 | Data dropped
67 |
68 |
69 |
{{.stats.DataFailed}} bytes
70 | Data failed
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 | Received |
80 | From |
81 | To |
82 | Subject |
83 | Filter |
84 | Route |
85 | Status |
86 |
87 |
88 |
89 | {{range $index, $log := .logs}}
90 |
91 | {{$log.Received}} |
92 | {{$log.From}} |
93 | {{$log.To}} |
94 | {{$log.Subject}} |
95 | {{$log.Filter}} |
96 | {{$log.Route}} |
97 | {{if eq $log.Error ""}}{{$log.Status}} | {{end}}
98 | {{if ne $log.Error ""}}{{$log.Status}} | {{end}}
99 |
100 | {{end}}
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
--------------------------------------------------------------------------------
/views/routes.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
Mailrouter
11 |
12 |
13 |
14 |
18 |
19 |
20 |
21 |
41 |
42 |
43 |
44 |
45 |
46 | {{if .info}}
{{.info}}
{{end}}
47 | {{if .error}}
{{.error}}
{{end}}
48 |
123 |
124 |
125 |
126 |
127 | Name |
128 | To |
129 | Host |
130 | |
131 |
132 |
133 |
134 |
135 | |
136 | |
137 | |
138 | |
139 |
140 |
141 |
142 | {{range $index, $route := .list}}
143 |
144 | {{$route.Name}}{{if $route.IsDefault}} Default{{end}} |
145 | {{$route.To}} |
146 | {{if ne $route.Id "DROP"}}{{$route.Hostname}}:{{$route.Port}}{{end}} |
147 |
148 | {{if not $route.IsDefault}}
149 | Make Default
150 | {{end}}
151 | {{if ne $route.Id "DROP"}}
152 | Edit
153 | Delete
154 | {{end}}
155 | |
156 |
157 | {{end}}
158 |
159 |
160 |
161 |
162 |
163 |
164 |
165 |
166 |
167 |
168 |
169 |
170 |
171 |
--------------------------------------------------------------------------------