├── LICENSE ├── README.md ├── config └── config.go ├── data ├── greylist.go ├── message.go ├── mongostore.go ├── storage.go └── user.go ├── etc └── smtpd.conf ├── incus ├── config.go ├── main_example.go.test ├── memory_store.go ├── message.go ├── redis_store.go ├── server.go ├── sockets.go └── store.go ├── log └── logging.go ├── main.go ├── smtpd ├── server.go └── utils.go ├── themes ├── cerber │ ├── public │ │ ├── css │ │ │ ├── bootstrap.css │ │ │ ├── bootstrap.min.css │ │ │ └── style.css │ │ ├── fonts │ │ │ ├── glyphicons-halflings-regular.eot │ │ │ ├── glyphicons-halflings-regular.svg │ │ │ ├── glyphicons-halflings-regular.ttf │ │ │ └── glyphicons-halflings-regular.woff │ │ ├── icons │ │ │ ├── Entypo_d83d(0)_128.png │ │ │ ├── Entypo_d83d(0)_256.png │ │ │ ├── Entypo_d83d(0)_48.png │ │ │ ├── Entypo_d83d(0)_64.png │ │ │ ├── icon-16.png │ │ │ ├── icon-24.png │ │ │ └── icon-32.png │ │ └── js │ │ │ ├── bootstrap.js │ │ │ ├── bootstrap.min.js │ │ │ ├── common.js │ │ │ ├── incus.js │ │ │ ├── jquery-1.11.1.min.js │ │ │ └── notify.js │ └── templates │ │ ├── common │ │ ├── login.html │ │ └── signup.html │ │ ├── layout.html │ │ ├── mailbox │ │ ├── _list.html │ │ └── _show.html │ │ └── root │ │ ├── index.html │ │ └── status.html └── greeting.html └── web ├── context.go ├── csrf.go ├── handlers.go ├── pagination.go ├── server.go └── template.go /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Gleez Technologies Pvt Ltd 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | smtpd 2 | ========================================================= 3 | 4 | A Lightweight High Performance SMTP written in Go, made for receiving 5 | large volumes of mail, parse and store in mongodb. The purpose of this daemon is 6 | to grab the email, save it to the database and disconnect as quickly as possible. 7 | 8 | This server does not attempt to check for spam or do any sender 9 | verification. These steps should be performed by other programs. 10 | The server does NOT send any email including bounces. This should 11 | be performed by a separate program. 12 | 13 | The most alluring aspect of Go are the Goroutines! It makes concurrent programming 14 | easy, clean and fun! Go programs can also take advantage of all your machine's multiple 15 | cores without much effort that you would otherwise need with forking or managing your 16 | event loop callbacks, etc. Golang solves the C10K problem in a very interesting way 17 | http://en.wikipedia.org/wiki/C10k_problem 18 | 19 | Once compiled, Smtpd does not have an external dependencies (HTTP, SMTP are all built in). 20 | 21 | Features 22 | ========================================================= 23 | 24 | * ESMTP server implementing RFC5321 25 | * Support for SMTP AUTH (RFC4954) and PIPELINING (RFC2920) 26 | * Multipart MIME support 27 | * UTF8 support for subject and message 28 | * Web interface to view messages (plain text, HTML or source) 29 | * Html sanitizer for html mail in web interface 30 | * Real-time updates using websocket 31 | * Download individual attachments 32 | * MongoDB storage for message persistence 33 | * Lightweight and portable 34 | * No installation required 35 | 36 | Development Status 37 | ========================================================= 38 | 39 | SMTPD is currently production quality: it is being used for real work. 40 | 41 | 42 | TODO 43 | ========================================================= 44 | 45 | * POP3 46 | * Rest API 47 | * Inline resources in Web interface 48 | * Per user/domain mailbox in web interface 49 | 50 | 51 | Building from Source 52 | ========================================================= 53 | 54 | You will need a functioning [Go installation][Golang] for this to work. 55 | 56 | Grab the Smtpd source code and compile the daemon: 57 | 58 | go get -v github.com/gleez/smtpd 59 | 60 | Edit etc/smtpd.conf and tailor to your environment. It should work on most 61 | Unix and OS X machines as is. Launch the daemon: 62 | 63 | $GOPATH/bin/smtpd -config=$GOPATH/src/github.com/gleez/smtpd/etc/smtpd.conf 64 | 65 | By default the SMTP server will be listening on localhost port 25000 and 66 | the web interface will be available at [localhost:10025](http://localhost:10025/). 67 | 68 | This will place smtpd in the background and continue running 69 | 70 | /usr/bin/nohup /home/gleez/smtpd -config=/home/gleez/smtpd.conf -logfile=smtpd.log 2>&1 & 71 | 72 | You may also put another process to watch your smtpd process and re-start it 73 | if something goes wrong. 74 | 75 | 76 | Using Nginx as a proxy 77 | ========================================================= 78 | Nginx can be used to proxy SMTP traffic for GoGuerrilla SMTPd 79 | 80 | Why proxy SMTP? 81 | 82 | * Terminate TLS connections: At present, only a partial implementation 83 | of TLS is provided. OpenSSL on the other hand, used in Nginx, has a complete 84 | implementation of SSL v2/v3 and TLS protocols. 85 | * Could be used for load balancing and authentication in the future. 86 | 87 | 1. Compile nginx with --with-mail --with-mail_ssl_module 88 | 2. Configuration: 89 | 90 | ``` 91 | mail { 92 | #This is the URL to Smtpd's http service which tells Nginx where to proxy the traffic to 93 | auth_http 127.0.0.1:10025/auth-smtp; 94 | 95 | server { 96 | listen 15.29.8.163:25; 97 | protocol smtp; 98 | server_name smtp.example.com; 99 | 100 | smtp_auth none; 101 | timeout 30000; 102 | smtp_capabilities "PIPELINING" "8BITMIME" "SIZE 20480000"; 103 | 104 | # ssl default off. Leave off if starttls is on 105 | #ssl on; 106 | ssl_certificate /etc/ssl/certs/ssl-cert-snakeoil.pem; 107 | ssl_certificate_key /etc/ssl/private/ssl-cert-snakeoil.key; 108 | ssl_session_timeout 5m; 109 | 110 | ssl_protocols SSLv2 SSLv3 TLSv1; 111 | ssl_ciphers HIGH:!aNULL:!MD5; 112 | ssl_prefer_server_ciphers on; 113 | 114 | # TLS off unless client issues STARTTLS command 115 | starttls on; 116 | proxy on; 117 | xclient on; 118 | } 119 | } 120 | ``` 121 | 122 | Credits 123 | ========================================================= 124 | * https://github.com/flashmob/go-guerrilla 125 | * https://github.com/jhillyerd/inbucket 126 | * https://github.com/ian-kent/Go-MailHog 127 | * https://github.com/briankassouf/incus 128 | * https://github.com/microcosm-cc/bluemonday 129 | * http://gorillatoolkit.org 130 | 131 | Licence 132 | ========================================================= 133 | 134 | Copyright © 2014, Gleez Technologies (http://www.gleeztech.com). 135 | 136 | Released under MIT license, see [LICENSE](license) for details. -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "container/list" 5 | "fmt" 6 | "net" 7 | "os" 8 | "strings" 9 | 10 | "github.com/robfig/config" 11 | ) 12 | 13 | // Used for message channel, avoids import cycle error 14 | type SMTPMessage struct { 15 | From string 16 | To []string 17 | Data string 18 | Helo string 19 | Host string 20 | Domain string 21 | Hash string 22 | Notify chan int 23 | } 24 | 25 | // SmtpConfig houses the SMTP server configuration - not using pointers 26 | // so that I can pass around copies of the object safely. 27 | type SmtpConfig struct { 28 | Ip4address net.IP 29 | Ip4port int 30 | Domain string 31 | AllowedHosts string 32 | TrustedHosts string 33 | MaxRecipients int 34 | MaxIdleSeconds int 35 | MaxClients int 36 | MaxMessageBytes int 37 | PubKey string 38 | PrvKey string 39 | StoreMessages bool 40 | Xclient bool 41 | HostGreyList bool 42 | FromGreyList bool 43 | RcptGreyList bool 44 | Debug bool 45 | DebugPath string 46 | SpamRegex string 47 | } 48 | 49 | type Pop3Config struct { 50 | Ip4address net.IP 51 | Ip4port int 52 | Domain string 53 | MaxIdleSeconds int 54 | } 55 | 56 | type WebConfig struct { 57 | Ip4address net.IP 58 | Ip4port int 59 | TemplateDir string 60 | TemplateCache bool 61 | PublicDir string 62 | GreetingFile string 63 | ClientBroadcasts bool 64 | ConnTimeout int 65 | RedisEnabled bool 66 | RedisHost string 67 | RedisPort int 68 | RedisChannel string 69 | CookieSecret string 70 | } 71 | 72 | type DataStoreConfig struct { 73 | MimeParser bool 74 | StoreMessages bool 75 | Storage string 76 | MongoUri string 77 | MongoDb string 78 | MongoColl string 79 | } 80 | 81 | var ( 82 | // Global goconfig object 83 | Config *config.Config 84 | 85 | // Parsed specific configs 86 | smtpConfig *SmtpConfig 87 | pop3Config *Pop3Config 88 | webConfig *WebConfig 89 | dataStoreConfig *DataStoreConfig 90 | ) 91 | 92 | // GetSmtpConfig returns a copy of the SmtpConfig object 93 | func GetSmtpConfig() SmtpConfig { 94 | return *smtpConfig 95 | } 96 | 97 | // GetPop3Config returns a copy of the Pop3Config object 98 | func GetPop3Config() Pop3Config { 99 | return *pop3Config 100 | } 101 | 102 | // GetWebConfig returns a copy of the WebConfig object 103 | func GetWebConfig() WebConfig { 104 | return *webConfig 105 | } 106 | 107 | // GetDataStoreConfig returns a copy of the DataStoreConfig object 108 | func GetDataStoreConfig() DataStoreConfig { 109 | return *dataStoreConfig 110 | } 111 | 112 | // LoadConfig loads the specified configuration file into inbucket.Config 113 | // and performs validations on it. 114 | func LoadConfig(filename string) error { 115 | var err error 116 | Config, err = config.ReadDefault(filename) 117 | if err != nil { 118 | return err 119 | } 120 | 121 | messages := list.New() 122 | 123 | // Validate sections 124 | requireSection(messages, "logging") 125 | requireSection(messages, "smtp") 126 | requireSection(messages, "pop3") 127 | requireSection(messages, "web") 128 | requireSection(messages, "datastore") 129 | if messages.Len() > 0 { 130 | fmt.Fprintln(os.Stderr, "Error(s) validating configuration:") 131 | for e := messages.Front(); e != nil; e = e.Next() { 132 | fmt.Fprintln(os.Stderr, " -", e.Value.(string)) 133 | } 134 | return fmt.Errorf("Failed to validate configuration") 135 | } 136 | 137 | // Validate options 138 | requireOption(messages, "logging", "level") 139 | requireOption(messages, "smtp", "ip4.address") 140 | requireOption(messages, "smtp", "ip4.port") 141 | requireOption(messages, "smtp", "domain") 142 | requireOption(messages, "smtp", "max.recipients") 143 | requireOption(messages, "smtp", "max.clients") 144 | requireOption(messages, "smtp", "max.idle.seconds") 145 | requireOption(messages, "smtp", "max.message.bytes") 146 | requireOption(messages, "smtp", "store.messages") 147 | requireOption(messages, "smtp", "xclient") 148 | requireOption(messages, "pop3", "ip4.address") 149 | requireOption(messages, "pop3", "ip4.port") 150 | requireOption(messages, "pop3", "domain") 151 | requireOption(messages, "pop3", "max.idle.seconds") 152 | requireOption(messages, "web", "ip4.address") 153 | requireOption(messages, "web", "ip4.port") 154 | requireOption(messages, "web", "template.dir") 155 | requireOption(messages, "web", "template.cache") 156 | requireOption(messages, "web", "public.dir") 157 | requireOption(messages, "web", "cookie.secret") 158 | requireOption(messages, "datastore", "storage") 159 | 160 | // Return error if validations failed 161 | if messages.Len() > 0 { 162 | fmt.Fprintln(os.Stderr, "Error(s) validating configuration:") 163 | for e := messages.Front(); e != nil; e = e.Next() { 164 | fmt.Fprintln(os.Stderr, " -", e.Value.(string)) 165 | } 166 | return fmt.Errorf("Failed to validate configuration") 167 | } 168 | 169 | if err = parseSmtpConfig(); err != nil { 170 | return err 171 | } 172 | 173 | if err = parsePop3Config(); err != nil { 174 | return err 175 | } 176 | 177 | if err = parseWebConfig(); err != nil { 178 | return err 179 | } 180 | 181 | if err = parseDataStoreConfig(); err != nil { 182 | return err 183 | } 184 | 185 | return nil 186 | } 187 | 188 | // parseLoggingConfig trying to catch config errors early 189 | func parseLoggingConfig() error { 190 | section := "logging" 191 | 192 | option := "level" 193 | str, err := Config.String(section, option) 194 | if err != nil { 195 | return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err) 196 | } 197 | switch strings.ToUpper(str) { 198 | case "TRACE", "INFO", "WARN", "ERROR": 199 | default: 200 | return fmt.Errorf("Invalid value provided for [%v]%v: '%v'", section, option, str) 201 | } 202 | return nil 203 | } 204 | 205 | // parseSmtpConfig trying to catch config errors early 206 | func parseSmtpConfig() error { 207 | smtpConfig = new(SmtpConfig) 208 | section := "smtp" 209 | 210 | // Parse IP4 address only, error on IP6. 211 | option := "ip4.address" 212 | str, err := Config.String(section, option) 213 | if err != nil { 214 | return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err) 215 | } 216 | addr := net.ParseIP(str) 217 | if addr == nil { 218 | return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err) 219 | } 220 | addr = addr.To4() 221 | if addr == nil { 222 | return fmt.Errorf("Failed to parse [%v]%v: '%v' not IPv4!", section, option, err) 223 | } 224 | smtpConfig.Ip4address = addr 225 | 226 | option = "ip4.port" 227 | smtpConfig.Ip4port, err = Config.Int(section, option) 228 | if err != nil { 229 | return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err) 230 | } 231 | 232 | option = "domain" 233 | str, err = Config.String(section, option) 234 | if err != nil { 235 | return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err) 236 | } 237 | smtpConfig.Domain = str 238 | 239 | option = "allowed.hosts" 240 | if Config.HasOption(section, option) { 241 | str, err = Config.String(section, option) 242 | if err != nil { 243 | return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err) 244 | } 245 | smtpConfig.AllowedHosts = str 246 | } 247 | 248 | option = "trusted.hosts" 249 | if Config.HasOption(section, option) { 250 | str, err = Config.String(section, option) 251 | if err != nil { 252 | return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err) 253 | } 254 | smtpConfig.TrustedHosts = str 255 | } 256 | 257 | option = "public.key" 258 | str, err = Config.String(section, option) 259 | if err != nil { 260 | return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err) 261 | } 262 | smtpConfig.PubKey = str 263 | 264 | option = "private.key" 265 | str, err = Config.String(section, option) 266 | if err != nil { 267 | return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err) 268 | } 269 | smtpConfig.PrvKey = str 270 | 271 | option = "max.clients" 272 | smtpConfig.MaxClients, err = Config.Int(section, option) 273 | if err != nil { 274 | smtpConfig.MaxClients = 50 275 | //return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err) 276 | } 277 | 278 | option = "max.recipients" 279 | smtpConfig.MaxRecipients, err = Config.Int(section, option) 280 | if err != nil { 281 | return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err) 282 | } 283 | 284 | option = "max.idle.seconds" 285 | smtpConfig.MaxIdleSeconds, err = Config.Int(section, option) 286 | if err != nil { 287 | return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err) 288 | } 289 | 290 | option = "max.message.bytes" 291 | smtpConfig.MaxMessageBytes, err = Config.Int(section, option) 292 | if err != nil { 293 | return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err) 294 | } 295 | 296 | option = "store.messages" 297 | flag, err := Config.Bool(section, option) 298 | if err != nil { 299 | return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err) 300 | } 301 | smtpConfig.StoreMessages = flag 302 | 303 | option = "xclient" 304 | flag, err = Config.Bool(section, option) 305 | if err != nil { 306 | return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err) 307 | } 308 | smtpConfig.Xclient = flag 309 | 310 | option = "greylist.host" 311 | if Config.HasOption(section, option) { 312 | flag, err = Config.Bool(section, option) 313 | if err != nil { 314 | return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err) 315 | } 316 | smtpConfig.HostGreyList = flag 317 | } 318 | 319 | option = "greylist.from" 320 | if Config.HasOption(section, option) { 321 | flag, err = Config.Bool(section, option) 322 | if err != nil { 323 | return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err) 324 | } 325 | smtpConfig.FromGreyList = flag 326 | } 327 | 328 | option = "greylist.to" 329 | if Config.HasOption(section, option) { 330 | flag, err = Config.Bool(section, option) 331 | if err != nil { 332 | return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err) 333 | } 334 | smtpConfig.RcptGreyList = flag 335 | } 336 | 337 | option = "debug" 338 | if Config.HasOption(section, option) { 339 | flag, err = Config.Bool(section, option) 340 | if err != nil { 341 | return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err) 342 | } 343 | smtpConfig.Debug = flag 344 | } else { 345 | smtpConfig.Debug = false 346 | } 347 | 348 | option = "debug.path" 349 | if Config.HasOption(section, option) { 350 | str, err := Config.String(section, option) 351 | if err != nil { 352 | return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err) 353 | } 354 | smtpConfig.DebugPath = str 355 | } else { 356 | smtpConfig.DebugPath = "/tmp/smtpd" 357 | } 358 | 359 | option = "spam.regex" 360 | if Config.HasOption(section, option) { 361 | str, err := Config.String(section, option) 362 | if err != nil { 363 | return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err) 364 | } 365 | smtpConfig.SpamRegex = str 366 | } 367 | 368 | return nil 369 | } 370 | 371 | // parsePop3Config trying to catch config errors early 372 | func parsePop3Config() error { 373 | pop3Config = new(Pop3Config) 374 | section := "pop3" 375 | 376 | // Parse IP4 address only, error on IP6. 377 | option := "ip4.address" 378 | str, err := Config.String(section, option) 379 | if err != nil { 380 | return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err) 381 | } 382 | addr := net.ParseIP(str) 383 | if addr == nil { 384 | return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err) 385 | } 386 | addr = addr.To4() 387 | if addr == nil { 388 | return fmt.Errorf("Failed to parse [%v]%v: '%v' not IPv4!", section, option, err) 389 | } 390 | pop3Config.Ip4address = addr 391 | 392 | option = "ip4.port" 393 | pop3Config.Ip4port, err = Config.Int(section, option) 394 | if err != nil { 395 | return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err) 396 | } 397 | 398 | option = "domain" 399 | str, err = Config.String(section, option) 400 | if err != nil { 401 | return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err) 402 | } 403 | pop3Config.Domain = str 404 | 405 | option = "max.idle.seconds" 406 | pop3Config.MaxIdleSeconds, err = Config.Int(section, option) 407 | if err != nil { 408 | return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err) 409 | } 410 | 411 | return nil 412 | } 413 | 414 | // parseWebConfig trying to catch config errors early 415 | func parseWebConfig() error { 416 | webConfig = new(WebConfig) 417 | section := "web" 418 | 419 | // Parse IP4 address only, error on IP6. 420 | option := "ip4.address" 421 | str, err := Config.String(section, option) 422 | if err != nil { 423 | return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err) 424 | } 425 | addr := net.ParseIP(str) 426 | if addr == nil { 427 | return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err) 428 | } 429 | addr = addr.To4() 430 | if addr == nil { 431 | return fmt.Errorf("Failed to parse [%v]%v: '%v' not IPv4!", section, option, err) 432 | } 433 | webConfig.Ip4address = addr 434 | 435 | option = "ip4.port" 436 | webConfig.Ip4port, err = Config.Int(section, option) 437 | if err != nil { 438 | return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err) 439 | } 440 | 441 | option = "template.dir" 442 | str, err = Config.String(section, option) 443 | if err != nil { 444 | return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err) 445 | } 446 | webConfig.TemplateDir = str 447 | 448 | option = "template.cache" 449 | flag, err := Config.Bool(section, option) 450 | if err != nil { 451 | return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err) 452 | } 453 | webConfig.TemplateCache = flag 454 | 455 | option = "public.dir" 456 | str, err = Config.String(section, option) 457 | if err != nil { 458 | return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err) 459 | } 460 | webConfig.PublicDir = str 461 | 462 | option = "greeting.file" 463 | str, err = Config.String(section, option) 464 | if err != nil { 465 | return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err) 466 | } 467 | webConfig.GreetingFile = str 468 | 469 | option = "cookie.secret" 470 | str, err = Config.String(section, option) 471 | if err != nil { 472 | return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err) 473 | } 474 | webConfig.CookieSecret = str 475 | 476 | option = "client.broadcasts" 477 | flag, err = Config.Bool(section, option) 478 | if err != nil { 479 | return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err) 480 | } 481 | webConfig.ClientBroadcasts = flag 482 | 483 | option = "redis.enabled" 484 | flag, err = Config.Bool(section, option) 485 | if err != nil { 486 | return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err) 487 | } 488 | webConfig.RedisEnabled = flag 489 | 490 | option = "connection.timeout" 491 | webConfig.ConnTimeout, err = Config.Int(section, option) 492 | if err != nil { 493 | return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err) 494 | } 495 | 496 | option = "redis.host" 497 | str, err = Config.String(section, option) 498 | if err != nil { 499 | return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err) 500 | } 501 | webConfig.RedisHost = str 502 | 503 | option = "redis.port" 504 | webConfig.RedisPort, err = Config.Int(section, option) 505 | if err != nil { 506 | return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err) 507 | } 508 | 509 | option = "redis.channel" 510 | str, err = Config.String(section, option) 511 | if err != nil { 512 | return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err) 513 | } 514 | webConfig.RedisChannel = str 515 | 516 | return nil 517 | } 518 | 519 | // parseDataStoreConfig trying to catch config errors early 520 | func parseDataStoreConfig() error { 521 | dataStoreConfig = new(DataStoreConfig) 522 | section := "datastore" 523 | 524 | option := "mime.parser" 525 | if Config.HasOption(section, option) { 526 | flag, err := Config.Bool(section, option) 527 | if err != nil { 528 | return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err) 529 | } 530 | dataStoreConfig.MimeParser = flag 531 | } else { 532 | dataStoreConfig.MimeParser = true 533 | } 534 | 535 | option = "storage" 536 | str, err := Config.String(section, option) 537 | if err != nil { 538 | return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err) 539 | } 540 | dataStoreConfig.Storage = str 541 | 542 | option = "mongo.uri" 543 | str, err = Config.String(section, option) 544 | if err != nil { 545 | return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err) 546 | } 547 | dataStoreConfig.MongoUri = str 548 | 549 | option = "mongo.db" 550 | str, err = Config.String(section, option) 551 | if err != nil { 552 | return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err) 553 | } 554 | dataStoreConfig.MongoDb = str 555 | 556 | option = "mongo.coll" 557 | str, err = Config.String(section, option) 558 | if err != nil { 559 | return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err) 560 | } 561 | dataStoreConfig.MongoColl = str 562 | 563 | return nil 564 | } 565 | 566 | // requireSection checks that a [section] is defined in the configuration file, 567 | // appending a message if not. 568 | func requireSection(messages *list.List, section string) { 569 | if !Config.HasSection(section) { 570 | messages.PushBack(fmt.Sprintf("Config section [%v] is required", section)) 571 | } 572 | } 573 | 574 | // requireOption checks that 'option' is defined in [section] of the config file, 575 | // appending a message if not. 576 | func requireOption(messages *list.List, section string, option string) { 577 | if !Config.HasOption(section, option) { 578 | messages.PushBack(fmt.Sprintf("Config option '%v' is required in section [%v]", option, section)) 579 | } 580 | } 581 | -------------------------------------------------------------------------------- /data/greylist.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "time" 5 | 6 | "gopkg.in/mgo.v2/bson" 7 | ) 8 | 9 | type GreyHost struct { 10 | Id bson.ObjectId `bson:"_id"` 11 | CreatedBy string 12 | Hostname string 13 | CreatedAt time.Time 14 | IsActive bool 15 | } 16 | 17 | type GreyMail struct { 18 | Id bson.ObjectId `bson:"_id"` 19 | CreatedBy string 20 | Type string 21 | Email string 22 | Local string 23 | Domain string 24 | CreatedAt time.Time 25 | IsActive bool 26 | } 27 | 28 | type SpamIP struct { 29 | Id bson.ObjectId `bson:"_id"` 30 | Hostname string 31 | IPAddress string 32 | Type string 33 | Email string 34 | CreatedAt time.Time 35 | IsActive bool 36 | } 37 | -------------------------------------------------------------------------------- /data/message.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "bytes" 5 | "encoding/base64" 6 | "fmt" 7 | "io" 8 | "mime" 9 | "mime/multipart" 10 | "net/mail" 11 | "regexp" 12 | "strings" 13 | "time" 14 | 15 | "github.com/alexcesaro/mail/quotedprintable" 16 | "github.com/gleez/smtpd/config" 17 | "github.com/gleez/smtpd/log" 18 | "github.com/sloonz/go-iconv" 19 | "gopkg.in/mgo.v2/bson" 20 | ) 21 | 22 | type Messages []Message 23 | 24 | type Message struct { 25 | Id string 26 | Subject string 27 | From *Path 28 | To []*Path 29 | Created time.Time 30 | Attachments []*Attachment 31 | Ip string 32 | Content *Content 33 | MIME *MIMEBody 34 | Starred bool 35 | Unread bool 36 | } 37 | 38 | type Path struct { 39 | Relays []string 40 | Mailbox string 41 | Domain string 42 | Params string 43 | } 44 | 45 | type Content struct { 46 | Headers map[string][]string 47 | TextBody string 48 | HtmlBody string 49 | Size int 50 | Body string 51 | } 52 | 53 | type MIMEBody struct { 54 | Parts []*MIMEPart 55 | } 56 | 57 | type MIMEPart struct { 58 | Headers map[string][]string 59 | Body string 60 | FileName string 61 | ContentType string 62 | Charset string 63 | MIMEVersion string 64 | TransferEncoding string 65 | Disposition string 66 | Size int 67 | } 68 | 69 | type Attachment struct { 70 | Id string 71 | Body string 72 | FileName string 73 | ContentType string 74 | Charset string 75 | MIMEVersion string 76 | TransferEncoding string 77 | Size int 78 | } 79 | 80 | // TODO support nested MIME content 81 | func ParseSMTPMessage(m *config.SMTPMessage, hostname string, mimeParser bool) *Message { 82 | arr := make([]*Path, 0) 83 | for _, path := range m.To { 84 | arr = append(arr, PathFromString(path)) 85 | } 86 | 87 | msg := &Message{ 88 | Id: bson.NewObjectId().Hex(), 89 | From: PathFromString(m.From), 90 | To: arr, 91 | Created: time.Now(), 92 | Ip: m.Host, 93 | Unread: true, 94 | Starred: false, 95 | } 96 | 97 | if mimeParser { 98 | msg.Content = &Content{Size: len(m.Data), Headers: make(map[string][]string, 0), Body: m.Data} 99 | // Read mail using standard mail package 100 | if rm, err := mail.ReadMessage(bytes.NewBufferString(m.Data)); err == nil { 101 | log.LogTrace("Reading Mail Message") 102 | msg.Content.Size = len(m.Data) 103 | msg.Content.Headers = rm.Header 104 | msg.Subject = MimeHeaderDecode(rm.Header.Get("Subject")) 105 | 106 | if mt, p, err := mime.ParseMediaType(rm.Header.Get("Content-Type")); err == nil { 107 | if strings.HasPrefix(mt, "multipart/") { 108 | log.LogTrace("Parsing MIME Message") 109 | MIMEBody := &MIMEBody{Parts: make([]*MIMEPart, 0)} 110 | if err := ParseMIME(MIMEBody, rm.Body, p["boundary"], msg); err == nil { 111 | log.LogTrace("Got multiparts %d", len(MIMEBody.Parts)) 112 | msg.MIME = MIMEBody 113 | } 114 | } else { 115 | setMailBody(rm, msg) 116 | } 117 | } else { 118 | setMailBody(rm, msg) 119 | } 120 | } else { 121 | msg.Content.TextBody = m.Data 122 | } 123 | } else { 124 | msg.Content = ContentFromString(m.Data) 125 | } 126 | 127 | recd := fmt.Sprintf("from %s ([%s]) by %s (Smtpd)\r\n for <%s>; %s\r\n", m.Helo, m.Host, hostname, msg.Id+"@"+hostname, time.Now().Format(time.RFC1123Z)) 128 | //msg.Content.Headers["Delivered-To"] = []string{msg.To} 129 | msg.Content.Headers["Message-ID"] = []string{msg.Id + "@" + hostname} 130 | msg.Content.Headers["Received"] = []string{recd} 131 | msg.Content.Headers["Return-Path"] = []string{"<" + m.From + ">"} 132 | return msg 133 | } 134 | 135 | // db.messages.find({ to:{ $elemMatch: { mailbox:"bob" } } }) 136 | // db.messages.find( { 'from.mailbox': "alex" } ) 137 | func PathFromString(path string) *Path { 138 | relays := make([]string, 0) 139 | email := path 140 | if strings.Contains(path, ":") { 141 | x := strings.SplitN(path, ":", 2) 142 | r, e := x[0], x[1] 143 | email = e 144 | relays = strings.Split(r, ",") 145 | } 146 | mailbox, domain := "", "" 147 | if strings.Contains(email, "@") { 148 | x := strings.SplitN(email, "@", 2) 149 | mailbox, domain = x[0], x[1] 150 | } else { 151 | mailbox = email 152 | } 153 | 154 | return &Path{ 155 | Relays: relays, 156 | Mailbox: mailbox, 157 | Domain: domain, 158 | Params: "", // FIXME? 159 | } 160 | } 161 | 162 | func ParseMIME(MIMEBody *MIMEBody, reader io.Reader, boundary string, message *Message) error { 163 | mr := multipart.NewReader(reader, boundary) 164 | 165 | for { 166 | mrp, err := mr.NextPart() 167 | if err != nil { 168 | if err == io.EOF { 169 | // This is a clean end-of-message signal 170 | break 171 | //log.Fatal("Error eof %s", err) 172 | } 173 | return err 174 | } 175 | 176 | if len(mrp.Header) == 0 { 177 | // Empty header probably means the part didn't using the correct trailing "--" 178 | // syntax to close its boundary. We will let this slide if this this the 179 | // last MIME part. 180 | if _, err := mr.NextPart(); err != nil { 181 | if err == io.EOF || strings.HasSuffix(err.Error(), "EOF") { 182 | // This is what we were hoping for 183 | break 184 | } else { 185 | return fmt.Errorf("Error at boundary %v: %v", boundary, err) 186 | } 187 | } 188 | 189 | return fmt.Errorf("Empty header at boundary %v", boundary) 190 | } 191 | 192 | ctype := mrp.Header.Get("Content-Type") 193 | if ctype == "" { 194 | fmt.Errorf("Missing Content-Type at boundary %v", boundary) 195 | } 196 | 197 | mediatype, mparams, err := mime.ParseMediaType(ctype) 198 | if err != nil { 199 | return err 200 | } 201 | 202 | encoding := mrp.Header.Get("Content-Transfer-Encoding") 203 | // Figure out our disposition, filename 204 | disposition, dparams, err := mime.ParseMediaType(mrp.Header.Get("Content-Disposition")) 205 | 206 | if strings.HasPrefix(mediatype, "multipart/") && mparams["boundary"] != "" { 207 | // Content is another multipart 208 | ParseMIME(MIMEBody, mrp, mparams["boundary"], message) 209 | } else { 210 | if n, body, err := Partbuf(mrp); err == nil { 211 | part := &MIMEPart{Size: int(n), Headers: mrp.Header, Body: string(body), FileName: ""} 212 | // Disposition is optional 213 | part.Disposition = disposition 214 | part.ContentType = mediatype 215 | part.TransferEncoding = encoding 216 | 217 | if mparams["charset"] != "" { 218 | part.Charset = mparams["charset"] 219 | } 220 | 221 | if disposition == "attachment" || disposition == "inline" { 222 | //log.LogTrace("Found attachment: '%s'", disposition) 223 | part.FileName = MimeHeaderDecode(dparams["filename"]) 224 | 225 | if part.FileName == "" && mparams["name"] != "" { 226 | part.FileName = MimeHeaderDecode(mparams["name"]) 227 | } 228 | } 229 | 230 | // Save attachments 231 | if disposition == "attachment" && len(part.FileName) > 0 { 232 | log.LogTrace("Found attachment: '%s'", disposition) 233 | //db.messages.find({ 'attachments.id': "54200a938b1864264c000005" }, {"attachments.$" : 1}) 234 | attachment := &Attachment{ 235 | Id: bson.NewObjectId().Hex(), 236 | Body: string(body), 237 | FileName: part.FileName, 238 | Charset: part.Charset, 239 | ContentType: mediatype, 240 | TransferEncoding: encoding, 241 | Size: int(n), 242 | } 243 | message.Attachments = append(message.Attachments, attachment) 244 | } else { 245 | MIMEBody.Parts = append(MIMEBody.Parts, part) 246 | } 247 | 248 | //use mediatype; ctype will have 'text/plain; charset=UTF-8' 249 | // attachments might be plain text content, so make sure of it 250 | if mediatype == "text/plain" && disposition != "attachment" { 251 | message.Content.TextBody = MimeBodyDecode(string(body), part.Charset, part.TransferEncoding) 252 | } 253 | 254 | if mediatype == "text/html" && disposition != "attachment" { 255 | message.Content.HtmlBody = MimeBodyDecode(string(body), part.Charset, part.TransferEncoding) 256 | } 257 | } else { 258 | log.LogError("Error Processing MIME message: <%s>", err) 259 | } 260 | } 261 | } 262 | 263 | return nil 264 | } 265 | 266 | func ContentFromString(data string) *Content { 267 | log.LogTrace("Parsing Content from string: <%d>", len(data)) 268 | x := strings.SplitN(data, "\r\n\r\n", 2) 269 | h := make(map[string][]string, 0) 270 | 271 | if len(x) == 2 { 272 | headers, body := x[0], x[1] 273 | hdrs := strings.Split(headers, "\r\n") 274 | var lastHdr = "" 275 | for _, hdr := range hdrs { 276 | if lastHdr != "" && strings.HasPrefix(hdr, " ") { 277 | h[lastHdr][len(h[lastHdr])-1] = h[lastHdr][len(h[lastHdr])-1] + hdr 278 | } else if strings.Contains(hdr, ": ") { 279 | y := strings.SplitN(hdr, ": ", 2) 280 | key, value := y[0], y[1] 281 | // TODO multiple header fields 282 | h[key] = []string{value} 283 | lastHdr = key 284 | } else { 285 | log.LogWarn("Found invalid header: '%s'", hdr) 286 | } 287 | } 288 | //log.LogTrace("Found body: '%s'", body) 289 | return &Content{ 290 | Size: len(data), 291 | Headers: h, 292 | Body: body, 293 | //Body: "", 294 | } 295 | } else { 296 | return &Content{ 297 | Size: len(data), 298 | Headers: h, 299 | Body: x[0], 300 | TextBody: x[0], 301 | } 302 | } 303 | } 304 | 305 | func Partbuf(reader io.Reader) (int64, []byte, error) { 306 | // Read bytes into buffer 307 | buf := new(bytes.Buffer) 308 | n, err := buf.ReadFrom(reader) 309 | if err != nil { 310 | return 0, nil, err 311 | } 312 | 313 | return n, buf.Bytes(), nil 314 | } 315 | 316 | // Decode strings in Mime header format 317 | // eg. =?ISO-2022-JP?B?GyRCIVo9dztSOWJAOCVBJWMbKEI=?= 318 | func MimeHeaderDecode(str string) string { 319 | //str, err := mail.DecodeRFC2047Word(str) 320 | str, charset, err := quotedprintable.DecodeHeader(str) 321 | charset = strings.ToUpper(charset) 322 | 323 | if err == nil && charset != "UTF-8" && len(charset) > 0 { 324 | charset = fixCharset(charset) 325 | // eg. charset can be "ISO-2022-JP" 326 | convstr, err := iconv.Conv(str, "UTF-8", charset) 327 | if err == nil { 328 | return convstr 329 | } 330 | } 331 | 332 | return str 333 | } 334 | 335 | func MimeBodyDecode(str string, charset string, encoding string) string { 336 | if encoding == "" { 337 | return str 338 | } 339 | 340 | encoding = strings.ToLower(encoding) 341 | if encoding == "base64" { 342 | dec, err := base64.StdEncoding.DecodeString(str) 343 | if err != nil { 344 | return str 345 | } 346 | str = string(dec) 347 | } 348 | 349 | if charset == "" { 350 | return str 351 | } 352 | 353 | charset = strings.ToUpper(charset) 354 | if charset != "UTF-8" { 355 | charset = fixCharset(charset) 356 | // eg. charset can be "ISO-2022-JP" 357 | if convstr, err := iconv.Conv(str, "UTF-8", charset); err == nil { 358 | return convstr 359 | } 360 | } 361 | 362 | return str 363 | } 364 | 365 | func fixCharset(charset string) string { 366 | reg, _ := regexp.Compile(`[_:.\/\\]`) 367 | fixed_charset := reg.ReplaceAllString(charset, "-") 368 | // Fix charset 369 | // borrowed from http://squirrelmail.svn.sourceforge.net/viewvc/squirrelmail/trunk/squirrelmail/include/languages.php?revision=13765&view=markup 370 | // OE ks_c_5601_1987 > cp949 371 | fixed_charset = strings.Replace(fixed_charset, "ks-c-5601-1987", "cp949", -1) 372 | // Moz x-euc-tw > euc-tw 373 | fixed_charset = strings.Replace(fixed_charset, "x-euc", "euc", -1) 374 | // Moz x-windows-949 > cp949 375 | fixed_charset = strings.Replace(fixed_charset, "x-windows_", "cp", -1) 376 | // windows-125x and cp125x charsets 377 | fixed_charset = strings.Replace(fixed_charset, "windows-", "cp", -1) 378 | // ibm > cp 379 | fixed_charset = strings.Replace(fixed_charset, "ibm", "cp", -1) 380 | // iso-8859-8-i -> iso-8859-8 381 | fixed_charset = strings.Replace(fixed_charset, "iso-8859-8-i", "iso-8859-8", -1) 382 | if charset != fixed_charset { 383 | return fixed_charset 384 | } 385 | return charset 386 | } 387 | 388 | func setMailBody(rm *mail.Message, msg *Message) { 389 | if _, body, err := Partbuf(rm.Body); err == nil { 390 | if bodyIsHTML(rm) { 391 | msg.Content.HtmlBody = string(body) 392 | } else { 393 | msg.Content.TextBody = string(body) 394 | } 395 | } 396 | } 397 | 398 | func bodyIsHTML(mr *mail.Message) bool { 399 | ctype := mr.Header.Get("Content-Type") 400 | if ctype == "" { 401 | return false 402 | } 403 | 404 | mediatype, _, err := mime.ParseMediaType(ctype) 405 | if err != nil { 406 | return false 407 | } 408 | 409 | // Figure out our disposition, filename 410 | disposition, _, err := mime.ParseMediaType(mr.Header.Get("Content-Disposition")) 411 | 412 | if mediatype == "text/html" && disposition != "attachment" && err == nil { 413 | return true 414 | } 415 | 416 | return false 417 | } 418 | -------------------------------------------------------------------------------- /data/mongostore.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/gleez/smtpd/config" 7 | "github.com/gleez/smtpd/log" 8 | 9 | "gopkg.in/mgo.v2" 10 | "gopkg.in/mgo.v2/bson" 11 | ) 12 | 13 | type MongoDB struct { 14 | Config config.DataStoreConfig 15 | Session *mgo.Session 16 | Messages *mgo.Collection 17 | Users *mgo.Collection 18 | Hosts *mgo.Collection 19 | Emails *mgo.Collection 20 | Spamdb *mgo.Collection 21 | } 22 | 23 | var ( 24 | mgoSession *mgo.Session 25 | ) 26 | 27 | func getSession(c config.DataStoreConfig) *mgo.Session { 28 | if mgoSession == nil { 29 | var err error 30 | mgoSession, err = mgo.Dial(c.MongoUri) 31 | if err != nil { 32 | log.LogError("Session Error connecting to MongoDB: %s", err) 33 | return nil 34 | } 35 | } 36 | return mgoSession.Clone() 37 | } 38 | 39 | func CreateMongoDB(c config.DataStoreConfig) *MongoDB { 40 | log.LogTrace("Connecting to MongoDB: %s\n", c.MongoUri) 41 | 42 | session, err := mgo.Dial(c.MongoUri) 43 | if err != nil { 44 | log.LogError("Error connecting to MongoDB: %s", err) 45 | return nil 46 | } 47 | 48 | return &MongoDB{ 49 | Config: c, 50 | Session: session, 51 | Messages: session.DB(c.MongoDb).C(c.MongoColl), 52 | Users: session.DB(c.MongoDb).C("Users"), 53 | Hosts: session.DB(c.MongoDb).C("GreyHosts"), 54 | Emails: session.DB(c.MongoDb).C("GreyMails"), 55 | Spamdb: session.DB(c.MongoDb).C("SpamDB"), 56 | } 57 | } 58 | 59 | func (mongo *MongoDB) Close() { 60 | mongo.Session.Close() 61 | } 62 | 63 | func (mongo *MongoDB) Store(m *Message) (string, error) { 64 | err := mongo.Messages.Insert(m) 65 | if err != nil { 66 | log.LogError("Error inserting message: %s", err) 67 | return "", err 68 | } 69 | return m.Id, nil 70 | } 71 | 72 | func (mongo *MongoDB) List(start int, limit int) (*Messages, error) { 73 | messages := &Messages{} 74 | err := mongo.Messages.Find(bson.M{}).Sort("-_id").Skip(start).Limit(limit).Select(bson.M{ 75 | "id": 1, 76 | "from": 1, 77 | "to": 1, 78 | "attachments": 1, 79 | "created": 1, 80 | "ip": 1, 81 | "subject": 1, 82 | "starred": 1, 83 | "unread": 1, 84 | }).All(messages) 85 | if err != nil { 86 | log.LogError("Error loading messages: %s", err) 87 | return nil, err 88 | } 89 | return messages, nil 90 | } 91 | 92 | func (mongo *MongoDB) DeleteOne(id string) error { 93 | _, err := mongo.Messages.RemoveAll(bson.M{"id": id}) 94 | return err 95 | } 96 | 97 | func (mongo *MongoDB) DeleteAll() error { 98 | _, err := mongo.Messages.RemoveAll(bson.M{}) 99 | return err 100 | } 101 | 102 | func (mongo *MongoDB) Load(id string) (*Message, error) { 103 | result := &Message{} 104 | err := mongo.Messages.Find(bson.M{"id": id}).One(&result) 105 | if err != nil { 106 | log.LogError("Error loading message: %s", err) 107 | return nil, err 108 | } 109 | return result, nil 110 | } 111 | 112 | func (mongo *MongoDB) Total() (int, error) { 113 | total, err := mongo.Messages.Find(bson.M{}).Count() 114 | if err != nil { 115 | log.LogError("Error loading message: %s", err) 116 | return -1, err 117 | } 118 | return total, nil 119 | } 120 | 121 | func (mongo *MongoDB) LoadAttachment(id string) (*Message, error) { 122 | result := &Message{} 123 | err := mongo.Messages.Find(bson.M{"attachments.id": id}).Select(bson.M{ 124 | "id": 1, 125 | "attachments.$": 1, 126 | }).One(&result) 127 | if err != nil { 128 | log.LogError("Error loading attachment: %s", err) 129 | return nil, err 130 | } 131 | return result, nil 132 | } 133 | 134 | //Login validates and returns a user object if they exist in the database. 135 | func (mongo *MongoDB) Login(username, password string) (*User, error) { 136 | u := &User{} 137 | err := mongo.Users.Find(bson.M{"username": username}).One(&u) 138 | if err != nil { 139 | log.LogError("Login error: %v", err) 140 | return nil, err 141 | } 142 | 143 | if ok := Validate_Password(u.Password, password); !ok { 144 | log.LogError("Invalid Password: %s", u.Username) 145 | return nil, fmt.Errorf("Invalid Password!") 146 | } 147 | 148 | return u, nil 149 | } 150 | 151 | func (mongo *MongoDB) StoreGreyHost(h *GreyHost) (string, error) { 152 | err := mongo.Hosts.Insert(h) 153 | if err != nil { 154 | log.LogError("Error inserting greylist ip: %s", err) 155 | return "", err 156 | } 157 | return h.Id.Hex(), nil 158 | } 159 | 160 | func (mongo *MongoDB) StoreGreyMail(m *GreyMail) (string, error) { 161 | err := mongo.Emails.Insert(m) 162 | if err != nil { 163 | log.LogError("Error inserting greylist email: %s", err) 164 | return "", err 165 | } 166 | return m.Id.Hex(), nil 167 | } 168 | 169 | func (mongo *MongoDB) IsGreyHost(hostname string) (int, error) { 170 | tl, err := mongo.Hosts.Find(bson.M{"hostname": hostname, "isactive": true}).Count() 171 | if err != nil { 172 | log.LogError("Error checking host greylist: %s", err) 173 | return -1, err 174 | } 175 | return tl, nil 176 | } 177 | 178 | func (mongo *MongoDB) IsGreyMail(email, t string) (int, error) { 179 | tl, err := mongo.Emails.Find(bson.M{"email": email, "type": t, "isactive": true}).Count() 180 | if err != nil { 181 | log.LogError("Error checking email greylist: %s", err) 182 | return -1, err 183 | } 184 | return tl, nil 185 | } 186 | 187 | func (mongo *MongoDB) StoreSpamIp(s SpamIP) (string, error) { 188 | err := mongo.Spamdb.Insert(s) 189 | if err != nil { 190 | log.LogError("Error inserting greylist ip: %s", err) 191 | return "", err 192 | } 193 | return s.Id.Hex(), nil 194 | } 195 | -------------------------------------------------------------------------------- /data/storage.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "time" 7 | 8 | "github.com/gleez/smtpd/config" 9 | "github.com/gleez/smtpd/log" 10 | "gopkg.in/mgo.v2/bson" 11 | ) 12 | 13 | type DataStore struct { 14 | Config config.DataStoreConfig 15 | Storage interface{} 16 | SaveMailChan chan *config.SMTPMessage 17 | NotifyMailChan chan interface{} 18 | } 19 | 20 | // DefaultDataStore creates a new DataStore object. 21 | func NewDataStore() *DataStore { 22 | cfg := config.GetDataStoreConfig() 23 | 24 | // Database Writing 25 | saveMailChan := make(chan *config.SMTPMessage, 256) 26 | 27 | // Websocket Notification 28 | notifyMailChan := make(chan interface{}, 256) 29 | 30 | return &DataStore{Config: cfg, SaveMailChan: saveMailChan, NotifyMailChan: notifyMailChan} 31 | } 32 | 33 | func (ds *DataStore) StorageConnect() { 34 | 35 | if ds.Config.Storage == "mongodb" { 36 | log.LogInfo("Trying MongoDB storage") 37 | s := CreateMongoDB(ds.Config) 38 | if s == nil { 39 | log.LogInfo("MongoDB storage unavailable") 40 | } else { 41 | log.LogInfo("Using MongoDB storage") 42 | ds.Storage = s 43 | } 44 | 45 | // start some savemail workers 46 | for i := 0; i < 3; i++ { 47 | go ds.SaveMail() 48 | } 49 | } 50 | } 51 | 52 | func (ds *DataStore) StorageDisconnect() { 53 | if ds.Config.Storage == "mongodb" { 54 | ds.Storage.(*MongoDB).Close() 55 | } 56 | } 57 | 58 | func (ds *DataStore) SaveMail() { 59 | log.LogTrace("Running SaveMail Rotuines") 60 | var err error 61 | var recon bool 62 | 63 | for { 64 | mc := <-ds.SaveMailChan 65 | msg := ParseSMTPMessage(mc, mc.Domain, ds.Config.MimeParser) 66 | 67 | if ds.Config.Storage == "mongodb" { 68 | mc.Hash, err = ds.Storage.(*MongoDB).Store(msg) 69 | 70 | // if mongo conection is broken, try to reconnect only once 71 | if err == io.EOF && !recon { 72 | log.LogWarn("Connection error trying to reconnect") 73 | ds.Storage = CreateMongoDB(ds.Config) 74 | recon = true 75 | 76 | //try to save again 77 | mc.Hash, err = ds.Storage.(*MongoDB).Store(msg) 78 | } 79 | 80 | if err == nil { 81 | recon = false 82 | log.LogTrace("Save Mail Client hash : <%s>", mc.Hash) 83 | mc.Notify <- 1 84 | 85 | //Notify web socket 86 | ds.NotifyMailChan <- mc.Hash 87 | } else { 88 | mc.Notify <- -1 89 | log.LogError("Error storing message: %s", err) 90 | } 91 | } 92 | } 93 | } 94 | 95 | // Check if host address is in greylist 96 | // h -> hostname client ip 97 | func (ds *DataStore) CheckGreyHost(h string) bool { 98 | to, err := ds.Storage.(*MongoDB).IsGreyHost(h) 99 | if err != nil { 100 | return false 101 | } 102 | 103 | return to > 0 104 | } 105 | 106 | // Check if email address is in greylist 107 | // t -> type (from/to) 108 | // m -> local mailbox 109 | // d -> domain 110 | // h -> client IP 111 | func (ds *DataStore) CheckGreyMail(t, m, d, h string) bool { 112 | e := fmt.Sprintf("%s@%s", m, d) 113 | to, err := ds.Storage.(*MongoDB).IsGreyMail(e, t) 114 | if err != nil { 115 | return false 116 | } 117 | 118 | return to > 0 119 | } 120 | 121 | func (ds *DataStore) SaveSpamIP(ip string, email string) { 122 | s := SpamIP{ 123 | Id: bson.NewObjectId(), 124 | CreatedAt: time.Now(), 125 | IsActive: true, 126 | Email: email, 127 | IPAddress: ip, 128 | } 129 | 130 | if _, err := ds.Storage.(*MongoDB).StoreSpamIp(s); err != nil { 131 | log.LogError("Error inserting Spam IPAddress: %s", err) 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /data/user.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "crypto/hmac" 5 | "crypto/md5" 6 | "crypto/sha1" 7 | "crypto/sha256" 8 | "encoding/hex" 9 | "strings" 10 | "time" 11 | 12 | "gopkg.in/mgo.v2/bson" 13 | ) 14 | 15 | type User struct { 16 | Id bson.ObjectId `bson:"_id"` 17 | Firstname string 18 | Lastname string 19 | Email string 20 | Username string 21 | Password string 22 | Avatar string 23 | Website string 24 | Location string 25 | Tagline string 26 | Bio string 27 | JoinedAt time.Time 28 | IsSuperuser bool 29 | IsActive bool 30 | ValidateCode string 31 | ResetCode string 32 | LastLoginTime time.Time 33 | LastLoginIp string 34 | LoginCount int64 35 | } 36 | 37 | type LoginForm struct { 38 | Username string 39 | Password string 40 | Token string 41 | 42 | Errors map[string]string 43 | } 44 | 45 | func (f *LoginForm) Validate() bool { 46 | f.Errors = make(map[string]string) 47 | 48 | if strings.TrimSpace(f.Username) == "" { 49 | f.Errors["Username"] = "Please enter a valid username" 50 | } 51 | 52 | if strings.TrimSpace(f.Password) == "" { 53 | f.Errors["Password"] = "Please enter a password" 54 | } 55 | 56 | /* re := regexp.MustCompile(".+@.+\\..+") 57 | matched := re.Match([]byte(f.Email)) 58 | if matched == false { 59 | f.Errors["Email"] = "Please enter a valid email address" 60 | }*/ 61 | 62 | return len(f.Errors) == 0 63 | } 64 | 65 | func Encrypt_Password(password string, salt []byte) string { 66 | if salt == nil { 67 | m := md5.New() 68 | m.Write([]byte(time.Now().String())) 69 | s := hex.EncodeToString(m.Sum(nil)) 70 | salt = []byte(s[2:10]) 71 | } 72 | mac := hmac.New(sha256.New, salt) 73 | mac.Write([]byte(password)) 74 | //s := fmt.Sprintf("%x", (mac.Sum(salt))) 75 | s := hex.EncodeToString(mac.Sum(nil)) 76 | 77 | hasher := sha1.New() 78 | hasher.Write([]byte(s)) 79 | 80 | //result := fmt.Sprintf("%x", (hasher.Sum(nil))) 81 | result := hex.EncodeToString(hasher.Sum(nil)) 82 | 83 | p := string(salt) + result 84 | 85 | return p 86 | } 87 | 88 | func Validate_Password(hashed string, input_password string) bool { 89 | salt := hashed[0:8] 90 | if hashed == Encrypt_Password(input_password, []byte(salt)) { 91 | return true 92 | } else { 93 | return false 94 | } 95 | return false 96 | } 97 | 98 | //SetPassword takes a plaintext password and hashes it with bcrypt and sets the 99 | //password field to the hash. 100 | func (u *User) SetPassword(password string) { 101 | u.Password = Encrypt_Password(password, nil) 102 | } 103 | -------------------------------------------------------------------------------- /etc/smtpd.conf: -------------------------------------------------------------------------------- 1 | # smtpd.conf 2 | # Sample smtpd configuration 3 | 4 | ############################################################################# 5 | [DEFAULT] 6 | 7 | # Not used directly, but is typically referenced below in %()s format. 8 | install.dir=. 9 | 10 | ############################################################################# 11 | [logging] 12 | 13 | # Options from least to most verbose: ERROR, WARN, INFO, TRACE 14 | level=TRACE 15 | 16 | ############################################################################# 17 | [smtp] 18 | 19 | # IPv4 address to listen for SMTP connections on. 20 | ip4.address=0.0.0.0 21 | 22 | # IPv4 port to listen for SMTP connections on. 23 | ip4.port=25000 24 | 25 | # used in SMTP greeting 26 | domain=smtpd.local 27 | 28 | # Allowable hosts 29 | allowed.hosts=localhost 30 | 31 | # Trusted hosts (IP address) 32 | trusted.hosts=127.0.0.1 33 | 34 | # Maximum number of RCPT TO: addresses we allow from clients, the SMTP 35 | # RFC recommends this be at least 100. 36 | max.recipients=100 37 | 38 | # Maximum number of clients we allow 39 | max.clients=500 40 | 41 | # How long we allow a network connection to be idle before hanging up on the 42 | # client, SMTP RFC recommends at least 5 minutes (300 seconds). 43 | max.idle.seconds=300 44 | 45 | # Maximum allowable size of message body in bytes (including attachments) 46 | max.message.bytes=20480000 47 | 48 | # TLS certificate keys 49 | public.key= 50 | private.key= 51 | 52 | # Should we place messages into the datastore, or just throw them away 53 | # (for load testing): true or false 54 | store.messages=true 55 | 56 | # Should we enable xclient: true or false 57 | xclient=true 58 | 59 | # Should we enable to save mail debug: true or false 60 | debug=false 61 | 62 | # Path to the datastore, mail will be written into directory during debug 63 | debug.path=/tmp/mails 64 | 65 | # The regular expression to check against the massage to drop as spam message 66 | spam.regex=email(.*?)@yandex.ru|my profile is here:|my name is Natalia|e-mail:(.*?)@yandex.ru 67 | 68 | # Host based greylist enable: true or false 69 | greylist.host=true 70 | 71 | # Mail from grey list enable: true or false 72 | greylist.from=true 73 | 74 | # Rcpt to grey list enable: true or false 75 | greylist.to=true 76 | 77 | ############################################################################# 78 | [pop3] 79 | 80 | # IPv4 address to listen for POP3 connections on. 81 | ip4.address=0.0.0.0 82 | 83 | # IPv4 port to listen for POP3 connections on. 84 | ip4.port=11000 85 | 86 | # used in POP3 greeting 87 | domain=smtpd.local 88 | 89 | # How long we allow a network connection to be idle before hanging up on the 90 | # client, POP3 RFC requires at least 10 minutes (600 seconds). 91 | max.idle.seconds=600 92 | 93 | ############################################################################# 94 | [web] 95 | 96 | # IPv4 address to serve HTTP web interface on 97 | ip4.address=0.0.0.0 98 | 99 | # IPv4 port to serve HTTP web interface on 100 | ip4.port=10025 101 | 102 | # Name of web theme to use 103 | theme=cerber 104 | 105 | # Path to the selected themes template files 106 | template.dir=%(install.dir)s/themes/%(theme)s/templates 107 | 108 | # Should we cache parsed templates (set to false during theme dev) 109 | template.cache=false 110 | 111 | # Path to the selected themes public (static) files 112 | public.dir=%(install.dir)s/themes/%(theme)s/public 113 | 114 | # Path to the greeting HTML displayed on front page, can 115 | # be moved out of installation dir for customization 116 | greeting.file=%(install.dir)s/themes/greeting.html 117 | 118 | # Cookie Salt 119 | cookie.secret=691ecc793cec36efce45585b28a652a82025488b86285f7397c44e0addc449c4d451c129ebb63430cf83c7b0a971b5a3 120 | 121 | # ----- Websocket support ----- 122 | 123 | # Bool; True if clients are allowed to send messages to other clients, false otherwise. 124 | client.broadcasts=true 125 | 126 | # How long to keep connections open for, in seconds; 0 means no timeout 127 | connection.timeout=0 128 | 129 | # Bool; Redis must be enabled if running incus in a cluster 130 | redis.enabled=false 131 | 132 | # If redis is enabled, specify host and port 133 | redis.host=localhost 134 | redis.port=6379 135 | 136 | # If redis is enabled, redis_message_channel is the redis channel incus will subscribe to for incomming messages from application. 137 | redis.channel=Incus 138 | 139 | ############################################################################# 140 | [datastore] 141 | 142 | # Message storage: memory (default) or mongodb 143 | storage=mongodb 144 | 145 | # MongoDB URI, e.g. 127.0.0.1:27017 146 | mongo.uri=127.0.0.1:27017 147 | 148 | # MongoDB database, e.g. mailhog 149 | mongo.db=Smtpd 150 | 151 | # MongoDB collection, e.g. messages 152 | mongo.coll=Messages 153 | -------------------------------------------------------------------------------- /incus/config.go: -------------------------------------------------------------------------------- 1 | package incus 2 | 3 | import ( 4 | "log" 5 | "strconv" 6 | 7 | //"github.com/briankassouf/cfg" 8 | ) 9 | 10 | type Configuration struct { 11 | vars map[string]string 12 | } 13 | 14 | func InitConfig(mymap map[string]string) Configuration { 15 | //mymap := make(map[string]string) 16 | /* err := cfg.Load("/etc/incus/incus.conf", mymap) 17 | if err != nil { 18 | log.Panic(err) 19 | }*/ 20 | 21 | return Configuration{mymap} 22 | } 23 | 24 | func (this *Configuration) Get(name string) string { 25 | val, ok := this.vars[name] 26 | if !ok { 27 | log.Panicf("Config Error: variable '%s' not found", name) 28 | } 29 | 30 | return val 31 | } 32 | 33 | func (this *Configuration) GetInt(name string) int { 34 | val, ok := this.vars[name] 35 | if !ok { 36 | log.Panicf("Config Error: variable '%s' not found", name) 37 | } 38 | 39 | i, err := strconv.Atoi(val) 40 | if err != nil { 41 | log.Panicf("Config Error: '%s' could not be cast as an int", name) 42 | } 43 | 44 | return i 45 | } 46 | 47 | func (this *Configuration) GetBool(name string) bool { 48 | val, ok := this.vars[name] 49 | if !ok { 50 | return false 51 | } 52 | 53 | return val == "true" 54 | } 55 | -------------------------------------------------------------------------------- /incus/main_example.go.test: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | "os" 8 | "os/signal" 9 | "runtime" 10 | "syscall" 11 | "time" 12 | ) 13 | 14 | var DEBUG bool 15 | var CLIENT_BROAD bool 16 | var store *Storage 17 | 18 | func mainTest() { 19 | if os.Getenv("GOMAXPROCS") == "" { 20 | runtime.GOMAXPROCS(runtime.NumCPU()) 21 | } 22 | 23 | store = nil 24 | signals := make(chan os.Signal, 1) 25 | 26 | defer func() { 27 | if err := recover(); err != nil { 28 | log.Printf("FATAL: %s", err) 29 | shutdown() 30 | } 31 | }() 32 | 33 | conf := initConfig() 34 | initLogger(conf) 35 | signal.Notify(signals, os.Interrupt, syscall.SIGTERM) 36 | InstallSignalHandlers(signals) 37 | 38 | store = initStore(&conf) 39 | 40 | go func() { 41 | for { 42 | log.Println(store.memory.clientCount) 43 | time.Sleep(20 * time.Second) 44 | } 45 | }() 46 | 47 | CLIENT_BROAD = conf.GetBool("client_broadcasts") 48 | server := createServer(&conf, store) 49 | 50 | go server.initAppListener() 51 | go server.initSocketListener() 52 | go server.initLongPollListener() 53 | go server.initPingListener() 54 | go server.sendHeartbeats() 55 | 56 | go listenAndServeTLS(conf) 57 | listenAndServe(conf) 58 | } 59 | 60 | func listenAndServe(conf Configuration) { 61 | listenAddr := fmt.Sprintf(":%s", conf.Get("listening_port")) 62 | err := http.ListenAndServe(listenAddr, nil) 63 | if err != nil { 64 | log.Fatal(err) 65 | } 66 | } 67 | 68 | func listenAndServeTLS(conf Configuration) { 69 | if conf.GetBool("tls_enabled") { 70 | tlsListenAddr := fmt.Sprintf(":%s", conf.Get("tls_port")) 71 | err := http.ListenAndServeTLS(tlsListenAddr, conf.Get("cert_file"), conf.Get("key_file"), nil) 72 | if err != nil { 73 | log.Println(err) 74 | log.Fatal(err) 75 | } 76 | } 77 | } 78 | 79 | func InstallSignalHandlers(signals chan os.Signal) { 80 | go func() { 81 | sig := <-signals 82 | log.Printf("%v caught, incus is going down...", sig) 83 | shutdown() 84 | }() 85 | } 86 | 87 | func initLogger(conf Configuration) { 88 | DEBUG = false 89 | if conf.Get("log_level") == "debug" { 90 | DEBUG = true 91 | } 92 | } 93 | 94 | func shutdown() { 95 | if store != nil { 96 | log.Println("clearing redis memory...") 97 | } 98 | 99 | log.Println("Terminated") 100 | os.Exit(0) 101 | } 102 | -------------------------------------------------------------------------------- /incus/memory_store.go: -------------------------------------------------------------------------------- 1 | package incus 2 | 3 | import "errors" 4 | 5 | type MemoryStore struct { 6 | clients map[string]map[string]*Socket 7 | pages map[string]map[string]*Socket 8 | 9 | clientCount int64 10 | } 11 | 12 | func (this *MemoryStore) Save(sock *Socket) error { 13 | user, exists := this.clients[sock.UID] 14 | 15 | if !exists { 16 | this.clientCount++ 17 | 18 | userMap := make(map[string]*Socket) 19 | userMap[sock.SID] = sock 20 | this.clients[sock.UID] = userMap 21 | 22 | return nil 23 | } 24 | 25 | _, exists = user[sock.SID] 26 | user[sock.SID] = sock 27 | if !exists { 28 | this.clientCount++ 29 | } 30 | 31 | return nil 32 | } 33 | 34 | func (this *MemoryStore) Remove(sock *Socket) error { 35 | user, exists := this.clients[sock.UID] 36 | if !exists { // only subtract if the client was in the store in the first place. 37 | return nil 38 | } 39 | 40 | _, exists = user[sock.SID] 41 | delete(user, sock.SID) 42 | if exists { 43 | this.clientCount-- 44 | } 45 | 46 | if len(user) == 0 { 47 | delete(this.clients, sock.UID) 48 | } 49 | 50 | return nil 51 | } 52 | 53 | func (this *MemoryStore) Client(UID string) (map[string]*Socket, error) { 54 | var client, exists = this.clients[UID] 55 | 56 | if !exists { 57 | return nil, errors.New("ClientID doesn't exist") 58 | } 59 | return client, nil 60 | } 61 | 62 | func (this *MemoryStore) Clients() map[string]map[string]*Socket { 63 | return this.clients 64 | } 65 | 66 | func (this *MemoryStore) Count() (int64, error) { 67 | return this.clientCount, nil 68 | } 69 | 70 | func (this *MemoryStore) SetPage(sock *Socket) error { 71 | page, exists := this.pages[sock.Page] 72 | if !exists { 73 | pageMap := make(map[string]*Socket) 74 | pageMap[sock.SID] = sock 75 | this.pages[sock.Page] = pageMap 76 | 77 | return nil 78 | } 79 | 80 | page[sock.SID] = sock 81 | 82 | return nil 83 | } 84 | 85 | func (this *MemoryStore) UnsetPage(sock *Socket) error { 86 | page, exists := this.pages[sock.Page] 87 | if !exists { 88 | return nil 89 | } 90 | 91 | delete(page, sock.SID) 92 | 93 | if len(page) == 0 { 94 | delete(this.pages, sock.Page) 95 | } 96 | 97 | return nil 98 | } 99 | 100 | func (this *MemoryStore) getPage(page string) map[string]*Socket { 101 | var p, exists = this.pages[page] 102 | if !exists { 103 | return nil 104 | } 105 | 106 | return p 107 | } 108 | -------------------------------------------------------------------------------- /incus/message.go: -------------------------------------------------------------------------------- 1 | package incus 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "log" 7 | "strings" 8 | "time" 9 | ) 10 | 11 | type CommandMsg struct { 12 | Command map[string]string `json:"command"` 13 | Message map[string]interface{} `json:"message,omitempty"` 14 | } 15 | 16 | type Message struct { 17 | Event string `json:"event"` 18 | Data map[string]interface{} `json:"data"` 19 | Time int64 `json:"time"` 20 | } 21 | 22 | func (this *CommandMsg) FromSocket(sock *Socket) { 23 | command, ok := this.Command["command"] 24 | if !ok { 25 | return 26 | } 27 | 28 | if DEBUG { 29 | log.Printf("Handling socket message of type %s\n", command) 30 | } 31 | 32 | switch strings.ToLower(command) { 33 | case "message": 34 | if !CLIENT_BROAD { 35 | return 36 | } 37 | 38 | if sock.Server.Store.StorageType == "redis" { 39 | this.forwardToRedis(sock.Server) 40 | return 41 | } 42 | 43 | this.sendMessage(sock.Server) 44 | 45 | case "setpage": 46 | page, ok := this.Command["page"] 47 | if !ok || page == "" { 48 | return 49 | } 50 | 51 | if sock.Page != "" { 52 | sock.Server.Store.UnsetPage(sock) //remove old page if it exists 53 | } 54 | 55 | sock.Page = page 56 | sock.Server.Store.SetPage(sock) // set new page 57 | } 58 | } 59 | 60 | func (this *CommandMsg) FromRedis(server *Server) { 61 | command, ok := this.Command["command"] 62 | if !ok { 63 | return 64 | } 65 | 66 | if DEBUG { 67 | log.Printf("Handling redis message of type %s\n", command) 68 | } 69 | 70 | switch strings.ToLower(command) { 71 | 72 | case "message": 73 | this.sendMessage(server) 74 | } 75 | } 76 | 77 | func (this *CommandMsg) FromApp(server *Server) { 78 | command, ok := this.Command["command"] 79 | if !ok { 80 | if DEBUG { 81 | log.Printf("Missing command in message %s\n", command) 82 | } 83 | return 84 | } 85 | 86 | if DEBUG { 87 | log.Printf("Handling app message of type %s\n", command) 88 | } 89 | 90 | switch strings.ToLower(command) { 91 | 92 | case "message": 93 | this.sendMessage(server) 94 | } 95 | } 96 | 97 | func (this *CommandMsg) formatMessage() (*Message, error) { 98 | event, e_ok := this.Message["event"].(string) 99 | data, b_ok := this.Message["data"].(map[string]interface{}) 100 | 101 | if !b_ok || !e_ok { 102 | if DEBUG { 103 | log.Printf("Could not format message") 104 | } 105 | return nil, errors.New("Could not format message") 106 | } 107 | 108 | msg := &Message{event, data, time.Now().UTC().Unix()} 109 | 110 | return msg, nil 111 | } 112 | 113 | func (this *CommandMsg) sendMessage(server *Server) { 114 | user, userok := this.Command["user"] 115 | page, pageok := this.Command["page"] 116 | 117 | if userok { 118 | this.messageUser(user, page, server) 119 | } else if pageok { 120 | this.messagePage(page, server) 121 | } else { 122 | this.messageAll(server) 123 | } 124 | } 125 | 126 | func (this *CommandMsg) messageUser(UID string, page string, server *Server) { 127 | msg, err := this.formatMessage() 128 | if err != nil { 129 | return 130 | } 131 | 132 | user, err := server.Store.Client(UID) 133 | if err != nil { 134 | return 135 | } 136 | 137 | for _, sock := range user { 138 | if page != "" && page != sock.Page { 139 | continue 140 | } 141 | 142 | if !sock.isClosed() { 143 | sock.buff <- msg 144 | } 145 | } 146 | } 147 | 148 | func (this *CommandMsg) messageAll(server *Server) { 149 | msg, err := this.formatMessage() 150 | if err != nil { 151 | return 152 | } 153 | 154 | clients := server.Store.Clients() 155 | 156 | for _, user := range clients { 157 | for _, sock := range user { 158 | if !sock.isClosed() { 159 | sock.buff <- msg 160 | } 161 | } 162 | } 163 | 164 | return 165 | } 166 | 167 | func (this *CommandMsg) messagePage(page string, server *Server) { 168 | msg, err := this.formatMessage() 169 | if err != nil { 170 | return 171 | } 172 | 173 | pageMap := server.Store.getPage(page) 174 | if pageMap == nil { 175 | return 176 | } 177 | 178 | for _, sock := range pageMap { 179 | if !sock.isClosed() { 180 | sock.buff <- msg 181 | } 182 | } 183 | 184 | return 185 | } 186 | 187 | func (this *CommandMsg) forwardToRedis(server *Server) { 188 | msg_str, _ := json.Marshal(this) 189 | server.Store.redis.Publish(server.Config.Get("redis_message_channel"), string(msg_str)) //pass the message into redis to send message across cluster 190 | } 191 | -------------------------------------------------------------------------------- /incus/redis_store.go: -------------------------------------------------------------------------------- 1 | package incus 2 | 3 | import ( 4 | "errors" 5 | "log" 6 | 7 | "github.com/gosexy/redis" 8 | ) 9 | 10 | const ClientsKey = "SocketClients" 11 | const PageKey = "PageClients" 12 | 13 | type RedisStore struct { 14 | clientsKey string 15 | pageKey string 16 | 17 | server string 18 | port uint 19 | pool redisPool 20 | } 21 | 22 | //connection pool implimentation 23 | type redisPool struct { 24 | connections chan *redis.Client 25 | maxIdle int 26 | connFn func() (*redis.Client, error) // function to create new connection. 27 | } 28 | 29 | func newRedisStore(redis_host string, redis_port uint) *RedisStore { 30 | 31 | return &RedisStore{ 32 | ClientsKey, 33 | PageKey, 34 | 35 | redis_host, 36 | redis_port, 37 | 38 | redisPool{ 39 | connections: make(chan *redis.Client, 6), 40 | maxIdle: 6, 41 | 42 | connFn: func() (*redis.Client, error) { 43 | client := redis.New() 44 | err := client.Connect(redis_host, redis_port) 45 | 46 | if err != nil { 47 | log.Printf("Redis connect failed: %s\n", err.Error()) 48 | return nil, err 49 | } 50 | 51 | return client, nil 52 | }, 53 | }, 54 | } 55 | 56 | } 57 | 58 | func (this *redisPool) Get() (*redis.Client, bool) { 59 | 60 | var conn *redis.Client 61 | select { 62 | case conn = <-this.connections: 63 | default: 64 | conn, err := this.connFn() 65 | if err != nil { 66 | return nil, false 67 | } 68 | 69 | return conn, true 70 | } 71 | 72 | if err := this.testConn(conn); err != nil { 73 | return this.Get() // if connection is bad, get the next one in line until base case is hit, then create new client 74 | } 75 | 76 | return conn, true 77 | } 78 | 79 | func (this *redisPool) Close(conn *redis.Client) { 80 | select { 81 | case this.connections <- conn: 82 | return 83 | default: 84 | conn.Quit() 85 | } 86 | } 87 | 88 | func (this *redisPool) testConn(conn *redis.Client) error { 89 | if _, err := conn.Ping(); err != nil { 90 | conn.Quit() 91 | return err 92 | } 93 | 94 | return nil 95 | } 96 | 97 | func (this *RedisStore) GetConn() (*redis.Client, error) { 98 | 99 | client, ok := this.pool.Get() 100 | if !ok { 101 | return nil, errors.New("Error while getting redis connection") 102 | } 103 | 104 | return client, nil 105 | } 106 | 107 | func (this *RedisStore) CloseConn(conn *redis.Client) { 108 | this.pool.Close(conn) 109 | } 110 | 111 | func (this *RedisStore) Subscribe(c chan []string, channel string) (*redis.Client, error) { 112 | consumer := redis.New() 113 | err := consumer.ConnectNonBlock(this.server, this.port) 114 | if err != nil { 115 | return nil, err 116 | } 117 | 118 | if _, err := consumer.Ping(); err != nil { 119 | return nil, err 120 | } 121 | 122 | go consumer.Subscribe(c, channel) 123 | <-c // ignore subscribe command 124 | 125 | return consumer, nil 126 | } 127 | 128 | func (this *RedisStore) Publish(channel string, message string) { 129 | publisher, err := this.GetConn() 130 | if err != nil { 131 | return 132 | } 133 | defer this.CloseConn(publisher) 134 | 135 | publisher.Publish(channel, message) 136 | } 137 | 138 | func (this *RedisStore) Save(sock *Socket) error { 139 | client, err := this.GetConn() 140 | if err != nil { 141 | return err 142 | } 143 | defer this.CloseConn(client) 144 | 145 | _, err = client.SAdd(this.clientsKey, sock.UID) 146 | if err != nil { 147 | return err 148 | } 149 | 150 | return nil 151 | } 152 | 153 | func (this *RedisStore) Remove(sock *Socket) error { 154 | client, err := this.GetConn() 155 | if err != nil { 156 | return err 157 | } 158 | defer this.CloseConn(client) 159 | 160 | _, err = client.SRem(this.clientsKey, sock.UID) 161 | if err != nil { 162 | return err 163 | } 164 | 165 | return nil 166 | } 167 | 168 | func (this *RedisStore) Clients() ([]string, error) { 169 | client, err := this.GetConn() 170 | if err != nil { 171 | return nil, err 172 | } 173 | defer this.CloseConn(client) 174 | 175 | socks, err1 := client.SMembers(this.clientsKey) 176 | if err1 != nil { 177 | return nil, err1 178 | } 179 | 180 | return socks, nil 181 | } 182 | 183 | func (this *RedisStore) Count() (int64, error) { 184 | client, err := this.GetConn() 185 | if err != nil { 186 | return 0, err 187 | } 188 | defer this.CloseConn(client) 189 | 190 | socks, err1 := client.SCard(this.clientsKey) 191 | if err1 != nil { 192 | return 0, err1 193 | } 194 | 195 | return socks, nil 196 | } 197 | 198 | func (this *RedisStore) SetPage(sock *Socket) error { 199 | client, err := this.GetConn() 200 | if err != nil { 201 | return err 202 | } 203 | defer this.CloseConn(client) 204 | 205 | _, err = client.HIncrBy(this.pageKey, sock.Page, 1) 206 | if err != nil { 207 | return err 208 | } 209 | 210 | return nil 211 | } 212 | 213 | func (this *RedisStore) UnsetPage(sock *Socket) error { 214 | client, err := this.GetConn() 215 | if err != nil { 216 | return err 217 | } 218 | defer this.CloseConn(client) 219 | 220 | var i int64 221 | i, err = client.HIncrBy(this.pageKey, sock.Page, -1) 222 | if err != nil { 223 | return err 224 | } 225 | 226 | if i <= 0 { 227 | client.HDel(this.pageKey, sock.Page) 228 | } 229 | 230 | return nil 231 | } 232 | -------------------------------------------------------------------------------- /incus/server.go: -------------------------------------------------------------------------------- 1 | package incus 2 | 3 | import ( 4 | "crypto/md5" 5 | "encoding/json" 6 | "io" 7 | "log" 8 | "net/http" 9 | "time" 10 | 11 | "github.com/gorilla/websocket" 12 | ) 13 | 14 | var DEBUG bool 15 | var CLIENT_BROAD bool 16 | 17 | const ( 18 | writeWait = 5 * time.Second 19 | pongWait = 1 * time.Second 20 | ) 21 | 22 | type Server struct { 23 | ID string 24 | Config *Configuration 25 | Store *Storage 26 | 27 | Debug bool 28 | timeout time.Duration 29 | } 30 | 31 | func CreateServer(conf *Configuration, store *Storage) *Server { 32 | hash := md5.New() 33 | io.WriteString(hash, time.Now().String()) 34 | id := string(hash.Sum(nil)) 35 | 36 | DEBUG = conf.GetBool("debug") 37 | CLIENT_BROAD = conf.GetBool("client_broadcasts") 38 | 39 | debug := conf.GetBool("debug") 40 | timeout := time.Duration(conf.GetInt("connection_timeout")) 41 | return &Server{ID: id, Config: conf, Store: store, Debug: debug, timeout: timeout} 42 | } 43 | 44 | func (this *Server) SocketListener(w http.ResponseWriter, r *http.Request) { 45 | if r.Method != "GET" { 46 | http.Error(w, "Method not allowed", 405) 47 | return 48 | } 49 | 50 | //if r.Header.Get("Origin") != "http://"+r.Host { 51 | // http.Error(w, "Origin not allowed", 403) 52 | // return 53 | // } 54 | 55 | ws, err := websocket.Upgrade(w, r, nil, 1024, 1024) 56 | if _, ok := err.(websocket.HandshakeError); ok { 57 | http.Error(w, "Not a websocket handshake", 400) 58 | return 59 | } else if err != nil { 60 | log.Println(err) 61 | return 62 | } 63 | 64 | defer func() { 65 | ws.Close() 66 | if this.Debug { 67 | log.Println("Web Socket Closed") 68 | } 69 | }() 70 | 71 | sock := newSocket(ws, nil, this, "") 72 | 73 | if this.Debug { 74 | log.Printf("Web Socket connected via %s\n", ws.RemoteAddr()) 75 | //log.Printf("Web Socket connected via %s\n", ws.LocalAddr()) 76 | log.Printf("Web Socket connected via %s\n", r.Header.Get("X-Forwarded-For")) 77 | } 78 | if err := sock.Authenticate(""); err != nil { 79 | if this.Debug { 80 | log.Printf("Web Socket Error: %s\n", err.Error()) 81 | } 82 | return 83 | } 84 | 85 | /* if this.Debug { 86 | log.Printf("Web Socket SID: %s, UID: %s\n", sock.SID, sock.UID) 87 | count, _ := this.Store.Count() 88 | log.Printf("Connected Clients: %d\n", count) 89 | }*/ 90 | 91 | go sock.listenForMessages() 92 | go sock.listenForWrites() 93 | 94 | if this.timeout <= 0 { // if timeout is 0 then wait forever and return when socket is done. 95 | <-sock.done 96 | return 97 | } 98 | 99 | select { 100 | case <-time.After(this.timeout * time.Second): 101 | sock.Close() 102 | return 103 | case <-sock.done: 104 | return 105 | } 106 | } 107 | 108 | func (this *Server) LongPollListener(w http.ResponseWriter, r *http.Request) { 109 | defer func() { 110 | r.Body.Close() 111 | if this.Debug { 112 | log.Println("Longpoll Socket Closed") 113 | } 114 | }() 115 | 116 | sock := newSocket(nil, w, this, "") 117 | w.Header().Set("Access-Control-Allow-Origin", "*") 118 | w.Header().Set("Content-Type", "application/json") 119 | w.Header().Set("Cache-Control", "private, no-store, no-cache, must-revalidate, post-check=0, pre-check=0") 120 | w.Header().Set("Connection", "keep-alive") 121 | //w.Header().Set("Content-Encoding", "gzip") 122 | w.WriteHeader(200) 123 | 124 | if this.Debug { 125 | log.Printf("Long poll connected via %v \n", r.RemoteAddr) 126 | log.Printf("Long poll connected via %s\n", r.Header.Get("X-Forwarded-For")) 127 | } 128 | 129 | if err := sock.Authenticate(r.FormValue("user")); err != nil { 130 | if this.Debug { 131 | log.Printf("Long Poll Error: %s\n", err.Error()) 132 | } 133 | return 134 | } 135 | 136 | /* if this.Debug { 137 | log.Printf("Poll Socket SID: %s, UID: %s\n", sock.SID, sock.UID) 138 | count, _ := this.Store.Count() 139 | log.Printf("Connected Clients: %d\n", count) 140 | }*/ 141 | 142 | page := r.FormValue("page") 143 | if page != "" { 144 | if sock.Page != "" { 145 | this.Store.UnsetPage(sock) //remove old page if it exists 146 | } 147 | 148 | sock.Page = page 149 | this.Store.SetPage(sock) 150 | } 151 | 152 | command := r.FormValue("command") 153 | if command != "" { 154 | var cmd = new(CommandMsg) 155 | json.Unmarshal([]byte(command), cmd) 156 | log.Printf("Longpoll cmd %v command %v", cmd, command) 157 | go cmd.FromSocket(sock) 158 | } 159 | 160 | go sock.listenForWrites() 161 | 162 | select { 163 | case <-time.After(30 * time.Second): 164 | sock.Close() 165 | return 166 | case <-sock.done: 167 | return 168 | } 169 | } 170 | 171 | func (this *Server) RedisListener() { 172 | if !this.Config.GetBool("redis_enabled") { 173 | return 174 | } 175 | 176 | rec := make(chan []string, 10000) 177 | consumer, err := this.Store.redis.Subscribe(rec, this.Config.Get("redis_message_channel")) 178 | if err != nil { 179 | log.Fatal("Couldn't subscribe to redis channel") 180 | } 181 | defer consumer.Quit() 182 | 183 | if this.Debug { 184 | log.Println("LISENING FOR REDIS MESSAGE") 185 | } 186 | var ms []string 187 | for { 188 | ms = <-rec 189 | 190 | var cmd = new(CommandMsg) 191 | json.Unmarshal([]byte(ms[2]), cmd) 192 | go cmd.FromRedis(this) 193 | } 194 | } 195 | 196 | func (this *Server) AppListener(msg interface{}) { 197 | if this.Debug { 198 | log.Printf("LISENING FOR APP MESSAGE %v\n", msg) 199 | } 200 | 201 | var cmd = new(CommandMsg) 202 | //Command 203 | c := make(map[string]string) 204 | c["command"] = "message" 205 | cmd.Command = c 206 | 207 | // Message data 208 | d := make(map[string]interface{}) 209 | d["mail"] = msg 210 | 211 | // Message 212 | m := make(map[string]interface{}) 213 | m["data"] = d 214 | m["event"] = "NewMail" 215 | cmd.Message = m 216 | 217 | go cmd.FromApp(this) 218 | } 219 | 220 | func (this *Server) SendHeartbeats() { 221 | for { 222 | time.Sleep(20 * time.Second) 223 | clients := this.Store.Clients() 224 | 225 | for _, user := range clients { 226 | for _, sock := range user { 227 | if sock.isWebsocket() { 228 | if !sock.isClosed() { 229 | sock.ws.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(pongWait)) 230 | } 231 | } 232 | } 233 | } 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /incus/sockets.go: -------------------------------------------------------------------------------- 1 | package incus 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | "strings" 10 | "time" 11 | 12 | "github.com/gorilla/websocket" 13 | ) 14 | 15 | var socketIds chan string 16 | 17 | func init() { 18 | socketIds = make(chan string) 19 | 20 | go func() { 21 | var i = 1 22 | for { 23 | i++ 24 | socketIds <- fmt.Sprintf("%v", i) 25 | } 26 | }() 27 | } 28 | 29 | func newSocket(ws *websocket.Conn, lp http.ResponseWriter, server *Server, UID string) *Socket { 30 | return &Socket{<-socketIds, UID, "", ws, lp, server, make(chan *Message, 1000), make(chan bool), false, server.Debug} 31 | } 32 | 33 | type Socket struct { 34 | SID string // socket ID, randomly generated 35 | UID string // User ID, passed in via client 36 | Page string // Current page, if set. 37 | 38 | ws *websocket.Conn 39 | lp http.ResponseWriter 40 | Server *Server 41 | 42 | buff chan *Message 43 | done chan bool 44 | closed bool 45 | 46 | Debug bool 47 | } 48 | 49 | func (this *Socket) isWebsocket() bool { 50 | return (this.ws != nil) 51 | } 52 | 53 | func (this *Socket) isLongPoll() bool { 54 | return (this.lp != nil) 55 | } 56 | 57 | func (this *Socket) isClosed() bool { 58 | return this.closed 59 | } 60 | 61 | func (this *Socket) Close() error { 62 | if !this.closed { 63 | this.closed = true 64 | 65 | if this.Page != "" { 66 | this.Server.Store.UnsetPage(this) 67 | this.Page = "" 68 | } 69 | 70 | this.Server.Store.Remove(this) 71 | close(this.done) 72 | 73 | if this.Debug { 74 | log.Printf("Socket SID: %s, UID: %s\n", this.SID, this.UID) 75 | count, _ := this.Server.Store.Count() 76 | log.Printf("Connected Clients: %d\n", count) 77 | } 78 | } 79 | 80 | return nil 81 | } 82 | 83 | func (this *Socket) Authenticate(UID string) error { 84 | 85 | if this.isWebsocket() { 86 | var message = new(CommandMsg) 87 | err := this.ws.ReadJSON(message) 88 | 89 | if this.Debug { 90 | log.Println(message.Command) 91 | } 92 | if err != nil { 93 | return err 94 | } 95 | 96 | command := message.Command["command"] 97 | if strings.ToLower(command) != "authenticate" { 98 | return errors.New("Error: Authenticate Expected.\n") 99 | } 100 | 101 | var ok bool 102 | UID, ok = message.Command["user"] 103 | if !ok { 104 | return errors.New("Error on Authenticate: Bad Input.\n") 105 | } 106 | } 107 | 108 | if UID == "" { 109 | return errors.New("Error on Authenticate: Bad Input.\n") 110 | } 111 | 112 | if this.Debug { 113 | log.Printf("saving UID as %s", UID) 114 | } 115 | this.UID = UID 116 | this.Server.Store.Save(this) 117 | 118 | if this.Debug { 119 | log.Printf("Socket SID: %s, UID: %s\n", this.SID, this.UID) 120 | count, _ := this.Server.Store.Count() 121 | log.Printf("Connected Clients: %d\n", count) 122 | } 123 | 124 | return nil 125 | } 126 | 127 | func (this *Socket) listenForMessages() { 128 | for { 129 | select { 130 | case <-this.done: 131 | return 132 | 133 | default: 134 | var command = new(CommandMsg) 135 | err := this.ws.ReadJSON(command) 136 | if err != nil { 137 | if this.Debug { 138 | log.Printf("Listen Message Error: %s\n", err.Error()) 139 | } 140 | 141 | go this.Close() 142 | return 143 | } 144 | 145 | if this.Debug { 146 | log.Println(command) 147 | } 148 | go command.FromSocket(this) 149 | } 150 | } 151 | } 152 | 153 | func (this *Socket) listenForWrites() { 154 | for { 155 | select { 156 | case message := <-this.buff: 157 | if this.Debug { 158 | log.Println("Sending:", message) 159 | } 160 | 161 | var err error 162 | if this.isWebsocket() { 163 | this.ws.SetWriteDeadline(time.Now().Add(writeWait)) 164 | err = this.ws.WriteJSON(message) 165 | } else { 166 | json_str, _ := json.Marshal(message) 167 | 168 | _, err = fmt.Fprint(this.lp, string(json_str)) 169 | } 170 | 171 | if this.isLongPoll() || err != nil { 172 | if this.Debug && err != nil { 173 | log.Printf("Error: %s\n", err.Error()) 174 | } 175 | 176 | go this.Close() 177 | return 178 | } 179 | 180 | case <-this.done: 181 | return 182 | } 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /incus/store.go: -------------------------------------------------------------------------------- 1 | package incus 2 | 3 | import "sync" 4 | 5 | type Storage struct { 6 | memory *MemoryStore 7 | redis *RedisStore 8 | StorageType string 9 | 10 | userMu sync.RWMutex 11 | pageMu sync.RWMutex 12 | } 13 | 14 | func InitStore(Config *Configuration) *Storage { 15 | store_type := "memory" 16 | var redisStore *RedisStore 17 | 18 | redis_enabled := Config.Get("redis_enabled") 19 | if redis_enabled == "true" { 20 | redis_host := Config.Get("redis_host") 21 | redis_port := uint(Config.GetInt("redis_port")) 22 | 23 | redisStore = newRedisStore(redis_host, redis_port) 24 | store_type = "redis" 25 | } 26 | 27 | var Store = Storage{ 28 | &MemoryStore{make(map[string]map[string]*Socket), make(map[string]map[string]*Socket), 0}, 29 | redisStore, 30 | store_type, 31 | 32 | sync.RWMutex{}, 33 | sync.RWMutex{}, 34 | } 35 | 36 | return &Store 37 | } 38 | 39 | func (this *Storage) Save(sock *Socket) error { 40 | this.userMu.Lock() 41 | this.memory.Save(sock) 42 | this.userMu.Unlock() 43 | 44 | if this.StorageType == "redis" { 45 | if err := this.redis.Save(sock); err != nil { 46 | return err 47 | } 48 | } 49 | 50 | return nil 51 | } 52 | 53 | func (this *Storage) Remove(sock *Socket) error { 54 | this.userMu.Lock() 55 | this.memory.Remove(sock) 56 | this.userMu.Unlock() 57 | 58 | if this.StorageType == "redis" { 59 | if err := this.redis.Remove(sock); err != nil { 60 | return err 61 | } 62 | } 63 | 64 | return nil 65 | } 66 | 67 | func (this *Storage) Client(UID string) (map[string]*Socket, error) { 68 | defer this.userMu.RUnlock() 69 | this.userMu.RLock() 70 | 71 | return this.memory.Client(UID) 72 | } 73 | 74 | func (this *Storage) Clients() map[string]map[string]*Socket { 75 | defer this.userMu.RUnlock() 76 | this.userMu.RLock() 77 | 78 | return this.memory.Clients() 79 | } 80 | 81 | func (this *Storage) ClientList() ([]string, error) { 82 | if this.StorageType == "redis" { 83 | return this.redis.Clients() 84 | } 85 | 86 | return nil, nil 87 | } 88 | 89 | func (this *Storage) Count() (int64, error) { 90 | if this.StorageType == "redis" { 91 | return this.redis.Count() 92 | } 93 | 94 | return this.memory.Count() 95 | } 96 | 97 | func (this *Storage) SetPage(sock *Socket) error { 98 | this.pageMu.Lock() 99 | this.memory.SetPage(sock) 100 | this.pageMu.Unlock() 101 | 102 | if this.StorageType == "redis" { 103 | if err := this.redis.SetPage(sock); err != nil { 104 | return err 105 | } 106 | } 107 | 108 | return nil 109 | } 110 | 111 | func (this *Storage) UnsetPage(sock *Socket) error { 112 | this.pageMu.Lock() 113 | this.memory.UnsetPage(sock) 114 | this.pageMu.Unlock() 115 | 116 | if this.StorageType == "redis" { 117 | if err := this.redis.UnsetPage(sock); err != nil { 118 | return err 119 | } 120 | } 121 | 122 | return nil 123 | } 124 | 125 | func (this *Storage) getPage(page string) map[string]*Socket { 126 | defer this.pageMu.RUnlock() 127 | this.pageMu.RLock() 128 | return this.memory.getPage(page) 129 | } 130 | -------------------------------------------------------------------------------- /log/logging.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "log" 5 | "strings" 6 | ) 7 | 8 | type LogLevel int 9 | 10 | const ( 11 | ERROR LogLevel = iota 12 | WARN 13 | INFO 14 | TRACE 15 | ) 16 | 17 | var MaxLogLevel LogLevel = TRACE 18 | 19 | // SetLogLevel sets MaxLogLevel based on the provided string 20 | func SetLogLevel(level string) (ok bool) { 21 | switch strings.ToUpper(level) { 22 | case "ERROR": 23 | MaxLogLevel = ERROR 24 | case "WARN": 25 | MaxLogLevel = WARN 26 | case "INFO": 27 | MaxLogLevel = INFO 28 | case "TRACE": 29 | MaxLogLevel = TRACE 30 | default: 31 | LogError("Unknown log level requested: %v", level) 32 | return false 33 | } 34 | return true 35 | } 36 | 37 | // Error logs a message to the 'standard' Logger (always) 38 | func LogError(msg string, args ...interface{}) { 39 | msg = "[ERROR] " + msg 40 | log.Printf(msg, args...) 41 | } 42 | 43 | // Warn logs a message to the 'standard' Logger if MaxLogLevel is >= WARN 44 | func LogWarn(msg string, args ...interface{}) { 45 | if MaxLogLevel >= WARN { 46 | msg = "[WARN ] " + msg 47 | log.Printf(msg, args...) 48 | } 49 | } 50 | 51 | // Info logs a message to the 'standard' Logger if MaxLogLevel is >= INFO 52 | func LogInfo(msg string, args ...interface{}) { 53 | if MaxLogLevel >= INFO { 54 | msg = "[INFO ] " + msg 55 | log.Printf(msg, args...) 56 | } 57 | } 58 | 59 | // Trace logs a message to the 'standard' Logger if MaxLogLevel is >= TRACE 60 | func LogTrace(msg string, args ...interface{}) { 61 | if MaxLogLevel >= TRACE { 62 | msg = "[TRACE] " + msg 63 | log.Printf(msg, args...) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | /* 2 | This is the smtpd daemon launcher 3 | ./smtpd -config=etc/smtpd.conf -logfile=smtpd.log & 4 | */ 5 | package main 6 | 7 | import ( 8 | "flag" 9 | "fmt" 10 | golog "log" 11 | "os" 12 | "os/signal" 13 | "runtime" 14 | "syscall" 15 | "time" 16 | 17 | "github.com/gleez/smtpd/config" 18 | "github.com/gleez/smtpd/data" 19 | "github.com/gleez/smtpd/log" 20 | "github.com/gleez/smtpd/smtpd" 21 | "github.com/gleez/smtpd/web" 22 | ) 23 | 24 | var ( 25 | // Build info, populated during linking by goxc 26 | VERSION = "1.1" 27 | BUILD_DATE = "undefined" 28 | 29 | // Command line flags 30 | help = flag.Bool("help", false, "Displays this help") 31 | pidfile = flag.String("pidfile", "none", "Write our PID into the specified file") 32 | logfile = flag.String("logfile", "stderr", "Write out log into the specified file") 33 | configfile = flag.String("config", "/etc/smtpd.conf", "Path to the configuration file") 34 | 35 | // startTime is used to calculate uptime of Smtpd 36 | startTime = time.Now() 37 | 38 | // The file we send log output to, will be nil for stderr or stdout 39 | logf *os.File 40 | 41 | // Server instances 42 | smtpServer *smtpd.Server 43 | 44 | /* pop3Server *pop3d.Server*/ 45 | ) 46 | 47 | func main() { 48 | 49 | flag.Parse() 50 | runtime.GOMAXPROCS(runtime.NumCPU()) 51 | 52 | if *help { 53 | flag.Usage() 54 | return 55 | } 56 | 57 | // Load & Parse config 58 | /* if flag.NArg() != 1 { 59 | flag.Usage() 60 | os.Exit(1) 61 | }*/ 62 | 63 | //err := config.LoadConfig(flag.Arg(0)) 64 | err := config.LoadConfig(*configfile) 65 | if err != nil { 66 | fmt.Fprintf(os.Stderr, "Failed to parse config: %v\n", err) 67 | os.Exit(1) 68 | } 69 | 70 | // Setup signal handler 71 | sigChan := make(chan os.Signal) 72 | signal.Notify(sigChan, syscall.SIGHUP, syscall.SIGTERM) 73 | go signalProcessor(sigChan) 74 | 75 | // Configure logging, close std* fds 76 | level, _ := config.Config.String("logging", "level") 77 | log.SetLogLevel(level) 78 | 79 | if *logfile != "stderr" { 80 | // stderr is the go logging default 81 | if *logfile == "stdout" { 82 | // set to stdout 83 | golog.SetOutput(os.Stdout) 84 | } else { 85 | err := openLogFile() 86 | if err != nil { 87 | fmt.Fprintf(os.Stderr, "%v", err) 88 | os.Exit(1) 89 | } 90 | defer closeLogFile() 91 | 92 | // close std* streams 93 | os.Stdout.Close() 94 | os.Stderr.Close() // Warning: this will hide panic() output 95 | os.Stdin.Close() 96 | os.Stdout = logf 97 | os.Stderr = logf 98 | } 99 | } 100 | 101 | log.LogInfo("Smtpd %v (%v) starting...", VERSION, BUILD_DATE) 102 | 103 | // Write pidfile if requested 104 | // TODO: Probably supposed to remove pidfile during shutdown 105 | if *pidfile != "none" { 106 | pidf, err := os.Create(*pidfile) 107 | if err != nil { 108 | log.LogError("Failed to create %v: %v", *pidfile, err) 109 | os.Exit(1) 110 | } 111 | defer pidf.Close() 112 | fmt.Fprintf(pidf, "%v\n", os.Getpid()) 113 | } 114 | 115 | // Grab our datastore 116 | ds := data.NewDataStore() 117 | 118 | // Start HTTP server 119 | web.Initialize(config.GetWebConfig(), ds) 120 | go web.Start() 121 | 122 | // Startup SMTP server, block until it exits 123 | smtpServer = smtpd.NewSmtpServer(config.GetSmtpConfig(), ds) 124 | smtpServer.Start() 125 | 126 | // Wait for active connections to finish 127 | smtpServer.Drain() 128 | } 129 | 130 | // openLogFile creates or appends to the logfile passed on commandline 131 | func openLogFile() error { 132 | // use specified log file 133 | var err error 134 | logf, err = os.OpenFile(*logfile, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0666) 135 | if err != nil { 136 | return fmt.Errorf("Failed to create %v: %v\n", *logfile, err) 137 | } 138 | golog.SetOutput(logf) 139 | log.LogTrace("Opened new logfile") 140 | return nil 141 | } 142 | 143 | // closeLogFile closes the current logfile 144 | func closeLogFile() error { 145 | log.LogTrace("Closing logfile") 146 | return logf.Close() 147 | } 148 | 149 | // signalProcessor is a goroutine that handles OS signals 150 | func signalProcessor(c <-chan os.Signal) { 151 | for { 152 | sig := <-c 153 | switch sig { 154 | case syscall.SIGHUP: 155 | // Rotate logs if configured 156 | if logf != nil { 157 | log.LogInfo("Recieved SIGHUP, cycling logfile") 158 | closeLogFile() 159 | openLogFile() 160 | } else { 161 | log.LogInfo("Ignoring SIGHUP, logfile not configured") 162 | } 163 | case syscall.SIGTERM: 164 | // Initiate shutdown 165 | log.LogInfo("Received SIGTERM, shutting down") 166 | go timedExit() 167 | web.Stop() 168 | if smtpServer != nil { 169 | smtpServer.Stop() 170 | } else { 171 | log.LogError("smtpServer was nil during shutdown") 172 | } 173 | } 174 | } 175 | } 176 | 177 | // timedExit is called as a goroutine during shutdown, it will force an exit after 15 seconds 178 | func timedExit() { 179 | time.Sleep(15 * time.Second) 180 | log.LogError("Smtpd clean shutdown timed out, forcing exit") 181 | os.Exit(0) 182 | } 183 | 184 | func init() { 185 | flag.Usage = func() { 186 | fmt.Fprintln(os.Stderr, "Usage of smtpd [options]:") 187 | flag.PrintDefaults() 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /smtpd/utils.go: -------------------------------------------------------------------------------- 1 | package smtpd 2 | 3 | import ( 4 | "bytes" 5 | "container/list" 6 | "crypto/sha1" 7 | "fmt" 8 | "io" 9 | "strings" 10 | ) 11 | 12 | // Take "user+ext" and return "user", aka the mailbox we'll store it in 13 | // Return error if it contains invalid characters, we don't accept anything 14 | // that must be quoted according to RFC3696. 15 | func ParseMailboxName(localPart string) (result string, err error) { 16 | if localPart == "" { 17 | return "", fmt.Errorf("Mailbox name cannot be empty") 18 | } 19 | result = strings.ToLower(localPart) 20 | 21 | invalid := make([]byte, 0, 10) 22 | 23 | for i := 0; i < len(result); i++ { 24 | c := result[i] 25 | switch { 26 | case 'a' <= c && c <= 'z': 27 | case '0' <= c && c <= '9': 28 | case bytes.IndexByte([]byte("!#$%&'*+-=/?^_`.{|}~"), c) >= 0: 29 | default: 30 | invalid = append(invalid, c) 31 | } 32 | } 33 | 34 | if len(invalid) > 0 { 35 | return "", fmt.Errorf("Mailbox name contained invalid character(s): %q", invalid) 36 | } 37 | 38 | if idx := strings.Index(result, "+"); idx > -1 { 39 | result = result[0:idx] 40 | } 41 | return result, nil 42 | } 43 | 44 | // Take a mailbox name and hash it into the directory we'll store it in 45 | func HashMailboxName(mailbox string) string { 46 | h := sha1.New() 47 | io.WriteString(h, mailbox) 48 | return fmt.Sprintf("%x", h.Sum(nil)) 49 | } 50 | 51 | // JoinStringList joins a List containing strings by commas 52 | func JoinStringList(listOfStrings *list.List) string { 53 | if listOfStrings.Len() == 0 { 54 | return "" 55 | } 56 | s := make([]string, 0, listOfStrings.Len()) 57 | for e := listOfStrings.Front(); e != nil; e = e.Next() { 58 | s = append(s, e.Value.(string)) 59 | } 60 | return strings.Join(s, ",") 61 | } 62 | 63 | // ValidateDomainPart returns true if the domain part complies to RFC3696, RFC1035 64 | func ValidateDomainPart(domain string) bool { 65 | if len(domain) == 0 { 66 | return false 67 | } 68 | if len(domain) > 255 { 69 | return false 70 | } 71 | if domain[len(domain)-1] != '.' { 72 | domain += "." 73 | } 74 | prev := '.' 75 | labelLen := 0 76 | hasAlphaNum := false 77 | 78 | for _, c := range domain { 79 | switch { 80 | case ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z') || 81 | ('0' <= c && c <= '9') || c == '_': 82 | // Must contain some of these to be a valid label 83 | hasAlphaNum = true 84 | labelLen++ 85 | case c == '-': 86 | if prev == '.' { 87 | // Cannot lead with hyphen 88 | return false 89 | } 90 | case c == '.': 91 | if prev == '.' || prev == '-' { 92 | // Cannot end with hyphen or double-dot 93 | return false 94 | } 95 | if labelLen > 63 { 96 | return false 97 | } 98 | if !hasAlphaNum { 99 | return false 100 | } 101 | labelLen = 0 102 | hasAlphaNum = false 103 | default: 104 | // Unknown character 105 | return false 106 | } 107 | prev = c 108 | } 109 | 110 | return true 111 | } 112 | 113 | // ParseEmailAddress unescapes an email address, and splits the local part from the domain part. 114 | // An error is returned if the local or domain parts fail validation following the guidelines 115 | // in RFC3696. 116 | func ParseEmailAddress(address string) (local string, domain string, err error) { 117 | if address == "" { 118 | return "", "", fmt.Errorf("Empty address") 119 | } 120 | if len(address) > 320 { 121 | return "", "", fmt.Errorf("Address exceeds 320 characters") 122 | } 123 | if address[0] == '@' { 124 | return "", "", fmt.Errorf("Address cannot start with @ symbol") 125 | } 126 | if address[0] == '.' { 127 | return "", "", fmt.Errorf("Address cannot start with a period") 128 | } 129 | 130 | // Loop over address parsing out local part 131 | buf := new(bytes.Buffer) 132 | prev := byte('.') 133 | inCharQuote := false 134 | inStringQuote := false 135 | LOOP: 136 | for i := 0; i < len(address); i++ { 137 | c := address[i] 138 | switch { 139 | case ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z'): 140 | // Letters are OK 141 | buf.WriteByte(c) 142 | inCharQuote = false 143 | case '0' <= c && c <= '9': 144 | // Numbers are OK 145 | buf.WriteByte(c) 146 | inCharQuote = false 147 | case bytes.IndexByte([]byte("!#$%&'*+-/=?^_`{|}~"), c) >= 0: 148 | // These specials can be used unquoted 149 | buf.WriteByte(c) 150 | inCharQuote = false 151 | case c == '.': 152 | // A single period is OK 153 | if prev == '.' { 154 | // Sequence of periods is not permitted 155 | return "", "", fmt.Errorf("Sequence of periods is not permitted") 156 | } 157 | buf.WriteByte(c) 158 | inCharQuote = false 159 | case c == '\\': 160 | inCharQuote = true 161 | case c == '"': 162 | if inCharQuote { 163 | buf.WriteByte(c) 164 | inCharQuote = false 165 | } else if inStringQuote { 166 | inStringQuote = false 167 | } else { 168 | if i == 0 { 169 | inStringQuote = true 170 | } else { 171 | return "", "", fmt.Errorf("Quoted string can only begin at start of address") 172 | } 173 | } 174 | case c == '@': 175 | if inCharQuote || inStringQuote { 176 | buf.WriteByte(c) 177 | inCharQuote = false 178 | } else { 179 | // End of local-part 180 | if i > 63 { 181 | return "", "", fmt.Errorf("Local part must not exceed 64 characters") 182 | } 183 | if prev == '.' { 184 | return "", "", fmt.Errorf("Local part cannot end with a period") 185 | } 186 | domain = address[i+1:] 187 | break LOOP 188 | } 189 | case c > 127: 190 | return "", "", fmt.Errorf("Characters outside of US-ASCII range not permitted") 191 | default: 192 | if inCharQuote || inStringQuote { 193 | buf.WriteByte(c) 194 | inCharQuote = false 195 | } else { 196 | return "", "", fmt.Errorf("Character %q must be quoted", c) 197 | } 198 | } 199 | prev = c 200 | } 201 | if inCharQuote { 202 | return "", "", fmt.Errorf("Cannot end address with unterminated quoted-pair") 203 | } 204 | if inStringQuote { 205 | return "", "", fmt.Errorf("Cannot end address with unterminated string quote") 206 | } 207 | 208 | if !ValidateDomainPart(domain) { 209 | return "", "", fmt.Errorf("Domain part validation failed") 210 | } 211 | 212 | return buf.String(), domain, nil 213 | } 214 | -------------------------------------------------------------------------------- /themes/cerber/public/css/style.css: -------------------------------------------------------------------------------- 1 | html { 2 | position: relative; 3 | min-height: 100%; 4 | } 5 | body { 6 | padding-top: 70px; 7 | padding-bottom: 30px; 8 | /* Margin bottom by footer height */ 9 | margin-bottom: 60px; 10 | } 11 | .navbar-header img { 12 | height: 35px; 13 | margin: 8px 0 0 5px; 14 | float: left; 15 | } 16 | .footer { 17 | position: absolute; 18 | bottom: 0; 19 | width: 100%; 20 | /* Set the fixed height of the footer here */ 21 | height: 60px; 22 | background-color: #f5f5f5; 23 | } 24 | .footer > .container { 25 | padding-right: 15px; 26 | padding-left: 15px; 27 | } 28 | .footer .container .text-muted { 29 | margin: 20px 0; 30 | } 31 | 32 | .nav-tabs .glyphicon:not(.no-margin) { margin-right:10px; } 33 | .tab-pane .list-group-item:first-child {border-top-right-radius: 0px;border-top-left-radius: 0px;} 34 | .tab-pane .list-group-item:last-child {border-bottom-right-radius: 0px;border-bottom-left-radius: 0px;} 35 | .tab-pane .list-group .checkbox { display: inline-block;margin: 0px; } 36 | .tab-pane .list-group input[type="checkbox"]{ margin-top: 2px; } 37 | .tab-pane .list-group .glyphicon { margin-right:5px; } 38 | .tab-pane .list-group .glyphicon:hover { color:#FFBC00; } 39 | a.list-group-item.unread { color: #222;background-color: #F3F3F3; font-weight: bold; } 40 | hr { margin-top: 5px;margin-bottom: 10px; } 41 | .nav-pills>li>a {padding: 5px 10px;} 42 | 43 | #mails > .list-group .list-group-item { 44 | padding: 10px, 1px; 45 | } 46 | 47 | a.list-group-item Div.star.col-md-1, a.list-group-item Div.attach.col-md-1{ 48 | width: 2%; 49 | /*padding: 0;*/ 50 | } 51 | a.list-group-item Div.star.col-md-1{ 52 | padding-left: 1px; 53 | } 54 | a.list-group-item Div.name.col-md-2{ 55 | width: 22%; 56 | } 57 | a.list-group-item Div.date.col-md-1{ 58 | padding-right: 0px; 59 | float: right; 60 | } 61 | a.list-group-item Div.attach.col-md-1{ 62 | 63 | } 64 | 65 | .status-dl-horizontal dt { 66 | width: 250px; 67 | } 68 | 69 | .status-dl-horizontal dd { 70 | margin-left: 260px; 71 | } 72 | 73 | 74 | .red{ 75 | color: #e96656; 76 | } 77 | .blue { 78 | color: #669AE1; 79 | } 80 | .pink1 { 81 | color: #F0776C; 82 | } 83 | /* 84 | Forked by Stan Williams http://stans-songs.com and http://stanwilliamsmusic.com 85 | */ 86 | .colorgraph { 87 | height: 5px; 88 | border-top: 0; 89 | background: #c4e17f; 90 | border-radius: 5px; 91 | background-image: -webkit-linear-gradient(left, #c4e17f, #c4e17f 12.5%, #f7fdca 12.5%, #f7fdca 25%, #fecf71 25%, #fecf71 37.5%, #f0776c 37.5%, #f0776c 50%, #db9dbe 50%, #db9dbe 62.5%, #c49cde 62.5%, #c49cde 75%, #669ae1 75%, #669ae1 87.5%, #62c2e4 87.5%, #62c2e4); 92 | background-image: -moz-linear-gradient(left, #c4e17f, #c4e17f 12.5%, #f7fdca 12.5%, #f7fdca 25%, #fecf71 25%, #fecf71 37.5%, #f0776c 37.5%, #f0776c 50%, #db9dbe 50%, #db9dbe 62.5%, #c49cde 62.5%, #c49cde 75%, #669ae1 75%, #669ae1 87.5%, #62c2e4 87.5%, #62c2e4); 93 | background-image: -o-linear-gradient(left, #c4e17f, #c4e17f 12.5%, #f7fdca 12.5%, #f7fdca 25%, #fecf71 25%, #fecf71 37.5%, #f0776c 37.5%, #f0776c 50%, #db9dbe 50%, #db9dbe 62.5%, #c49cde 62.5%, #c49cde 75%, #669ae1 75%, #669ae1 87.5%, #62c2e4 87.5%, #62c2e4); 94 | background-image: linear-gradient(to right, #c4e17f, #c4e17f 12.5%, #f7fdca 12.5%, #f7fdca 25%, #fecf71 25%, #fecf71 37.5%, #f0776c 37.5%, #f0776c 50%, #db9dbe 50%, #db9dbe 62.5%, #c49cde 62.5%, #c49cde 75%, #669ae1 75%, #669ae1 87.5%, #62c2e4 87.5%, #62c2e4); 95 | } -------------------------------------------------------------------------------- /themes/cerber/public/fonts/glyphicons-halflings-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gleez/smtpd/65e9c33e895a4408a90472022ea8177eb419cbec/themes/cerber/public/fonts/glyphicons-halflings-regular.eot -------------------------------------------------------------------------------- /themes/cerber/public/fonts/glyphicons-halflings-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gleez/smtpd/65e9c33e895a4408a90472022ea8177eb419cbec/themes/cerber/public/fonts/glyphicons-halflings-regular.ttf -------------------------------------------------------------------------------- /themes/cerber/public/fonts/glyphicons-halflings-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gleez/smtpd/65e9c33e895a4408a90472022ea8177eb419cbec/themes/cerber/public/fonts/glyphicons-halflings-regular.woff -------------------------------------------------------------------------------- /themes/cerber/public/icons/Entypo_d83d(0)_128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gleez/smtpd/65e9c33e895a4408a90472022ea8177eb419cbec/themes/cerber/public/icons/Entypo_d83d(0)_128.png -------------------------------------------------------------------------------- /themes/cerber/public/icons/Entypo_d83d(0)_256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gleez/smtpd/65e9c33e895a4408a90472022ea8177eb419cbec/themes/cerber/public/icons/Entypo_d83d(0)_256.png -------------------------------------------------------------------------------- /themes/cerber/public/icons/Entypo_d83d(0)_48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gleez/smtpd/65e9c33e895a4408a90472022ea8177eb419cbec/themes/cerber/public/icons/Entypo_d83d(0)_48.png -------------------------------------------------------------------------------- /themes/cerber/public/icons/Entypo_d83d(0)_64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gleez/smtpd/65e9c33e895a4408a90472022ea8177eb419cbec/themes/cerber/public/icons/Entypo_d83d(0)_64.png -------------------------------------------------------------------------------- /themes/cerber/public/icons/icon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gleez/smtpd/65e9c33e895a4408a90472022ea8177eb419cbec/themes/cerber/public/icons/icon-16.png -------------------------------------------------------------------------------- /themes/cerber/public/icons/icon-24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gleez/smtpd/65e9c33e895a4408a90472022ea8177eb419cbec/themes/cerber/public/icons/icon-24.png -------------------------------------------------------------------------------- /themes/cerber/public/icons/icon-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gleez/smtpd/65e9c33e895a4408a90472022ea8177eb419cbec/themes/cerber/public/icons/icon-32.png -------------------------------------------------------------------------------- /themes/cerber/public/js/bootstrap.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap v3.2.0 (http://getbootstrap.com) 3 | * Copyright 2011-2014 Twitter, Inc. 4 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) 5 | */ 6 | if("undefined"==typeof jQuery)throw new Error("Bootstrap's JavaScript requires jQuery");+function(a){"use strict";function b(){var a=document.createElement("bootstrap"),b={WebkitTransition:"webkitTransitionEnd",MozTransition:"transitionend",OTransition:"oTransitionEnd otransitionend",transition:"transitionend"};for(var c in b)if(void 0!==a.style[c])return{end:b[c]};return!1}a.fn.emulateTransitionEnd=function(b){var c=!1,d=this;a(this).one("bsTransitionEnd",function(){c=!0});var e=function(){c||a(d).trigger(a.support.transition.end)};return setTimeout(e,b),this},a(function(){a.support.transition=b(),a.support.transition&&(a.event.special.bsTransitionEnd={bindType:a.support.transition.end,delegateType:a.support.transition.end,handle:function(b){return a(b.target).is(this)?b.handleObj.handler.apply(this,arguments):void 0}})})}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var c=a(this),e=c.data("bs.alert");e||c.data("bs.alert",e=new d(this)),"string"==typeof b&&e[b].call(c)})}var c='[data-dismiss="alert"]',d=function(b){a(b).on("click",c,this.close)};d.VERSION="3.2.0",d.prototype.close=function(b){function c(){f.detach().trigger("closed.bs.alert").remove()}var d=a(this),e=d.attr("data-target");e||(e=d.attr("href"),e=e&&e.replace(/.*(?=#[^\s]*$)/,""));var f=a(e);b&&b.preventDefault(),f.length||(f=d.hasClass("alert")?d:d.parent()),f.trigger(b=a.Event("close.bs.alert")),b.isDefaultPrevented()||(f.removeClass("in"),a.support.transition&&f.hasClass("fade")?f.one("bsTransitionEnd",c).emulateTransitionEnd(150):c())};var e=a.fn.alert;a.fn.alert=b,a.fn.alert.Constructor=d,a.fn.alert.noConflict=function(){return a.fn.alert=e,this},a(document).on("click.bs.alert.data-api",c,d.prototype.close)}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.button"),f="object"==typeof b&&b;e||d.data("bs.button",e=new c(this,f)),"toggle"==b?e.toggle():b&&e.setState(b)})}var c=function(b,d){this.$element=a(b),this.options=a.extend({},c.DEFAULTS,d),this.isLoading=!1};c.VERSION="3.2.0",c.DEFAULTS={loadingText:"loading..."},c.prototype.setState=function(b){var c="disabled",d=this.$element,e=d.is("input")?"val":"html",f=d.data();b+="Text",null==f.resetText&&d.data("resetText",d[e]()),d[e](null==f[b]?this.options[b]:f[b]),setTimeout(a.proxy(function(){"loadingText"==b?(this.isLoading=!0,d.addClass(c).attr(c,c)):this.isLoading&&(this.isLoading=!1,d.removeClass(c).removeAttr(c))},this),0)},c.prototype.toggle=function(){var a=!0,b=this.$element.closest('[data-toggle="buttons"]');if(b.length){var c=this.$element.find("input");"radio"==c.prop("type")&&(c.prop("checked")&&this.$element.hasClass("active")?a=!1:b.find(".active").removeClass("active")),a&&c.prop("checked",!this.$element.hasClass("active")).trigger("change")}a&&this.$element.toggleClass("active")};var d=a.fn.button;a.fn.button=b,a.fn.button.Constructor=c,a.fn.button.noConflict=function(){return a.fn.button=d,this},a(document).on("click.bs.button.data-api",'[data-toggle^="button"]',function(c){var d=a(c.target);d.hasClass("btn")||(d=d.closest(".btn")),b.call(d,"toggle"),c.preventDefault()})}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.carousel"),f=a.extend({},c.DEFAULTS,d.data(),"object"==typeof b&&b),g="string"==typeof b?b:f.slide;e||d.data("bs.carousel",e=new c(this,f)),"number"==typeof b?e.to(b):g?e[g]():f.interval&&e.pause().cycle()})}var c=function(b,c){this.$element=a(b).on("keydown.bs.carousel",a.proxy(this.keydown,this)),this.$indicators=this.$element.find(".carousel-indicators"),this.options=c,this.paused=this.sliding=this.interval=this.$active=this.$items=null,"hover"==this.options.pause&&this.$element.on("mouseenter.bs.carousel",a.proxy(this.pause,this)).on("mouseleave.bs.carousel",a.proxy(this.cycle,this))};c.VERSION="3.2.0",c.DEFAULTS={interval:5e3,pause:"hover",wrap:!0},c.prototype.keydown=function(a){switch(a.which){case 37:this.prev();break;case 39:this.next();break;default:return}a.preventDefault()},c.prototype.cycle=function(b){return b||(this.paused=!1),this.interval&&clearInterval(this.interval),this.options.interval&&!this.paused&&(this.interval=setInterval(a.proxy(this.next,this),this.options.interval)),this},c.prototype.getItemIndex=function(a){return this.$items=a.parent().children(".item"),this.$items.index(a||this.$active)},c.prototype.to=function(b){var c=this,d=this.getItemIndex(this.$active=this.$element.find(".item.active"));return b>this.$items.length-1||0>b?void 0:this.sliding?this.$element.one("slid.bs.carousel",function(){c.to(b)}):d==b?this.pause().cycle():this.slide(b>d?"next":"prev",a(this.$items[b]))},c.prototype.pause=function(b){return b||(this.paused=!0),this.$element.find(".next, .prev").length&&a.support.transition&&(this.$element.trigger(a.support.transition.end),this.cycle(!0)),this.interval=clearInterval(this.interval),this},c.prototype.next=function(){return this.sliding?void 0:this.slide("next")},c.prototype.prev=function(){return this.sliding?void 0:this.slide("prev")},c.prototype.slide=function(b,c){var d=this.$element.find(".item.active"),e=c||d[b](),f=this.interval,g="next"==b?"left":"right",h="next"==b?"first":"last",i=this;if(!e.length){if(!this.options.wrap)return;e=this.$element.find(".item")[h]()}if(e.hasClass("active"))return this.sliding=!1;var j=e[0],k=a.Event("slide.bs.carousel",{relatedTarget:j,direction:g});if(this.$element.trigger(k),!k.isDefaultPrevented()){if(this.sliding=!0,f&&this.pause(),this.$indicators.length){this.$indicators.find(".active").removeClass("active");var l=a(this.$indicators.children()[this.getItemIndex(e)]);l&&l.addClass("active")}var m=a.Event("slid.bs.carousel",{relatedTarget:j,direction:g});return a.support.transition&&this.$element.hasClass("slide")?(e.addClass(b),e[0].offsetWidth,d.addClass(g),e.addClass(g),d.one("bsTransitionEnd",function(){e.removeClass([b,g].join(" ")).addClass("active"),d.removeClass(["active",g].join(" ")),i.sliding=!1,setTimeout(function(){i.$element.trigger(m)},0)}).emulateTransitionEnd(1e3*d.css("transition-duration").slice(0,-1))):(d.removeClass("active"),e.addClass("active"),this.sliding=!1,this.$element.trigger(m)),f&&this.cycle(),this}};var d=a.fn.carousel;a.fn.carousel=b,a.fn.carousel.Constructor=c,a.fn.carousel.noConflict=function(){return a.fn.carousel=d,this},a(document).on("click.bs.carousel.data-api","[data-slide], [data-slide-to]",function(c){var d,e=a(this),f=a(e.attr("data-target")||(d=e.attr("href"))&&d.replace(/.*(?=#[^\s]+$)/,""));if(f.hasClass("carousel")){var g=a.extend({},f.data(),e.data()),h=e.attr("data-slide-to");h&&(g.interval=!1),b.call(f,g),h&&f.data("bs.carousel").to(h),c.preventDefault()}}),a(window).on("load",function(){a('[data-ride="carousel"]').each(function(){var c=a(this);b.call(c,c.data())})})}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.collapse"),f=a.extend({},c.DEFAULTS,d.data(),"object"==typeof b&&b);!e&&f.toggle&&"show"==b&&(b=!b),e||d.data("bs.collapse",e=new c(this,f)),"string"==typeof b&&e[b]()})}var c=function(b,d){this.$element=a(b),this.options=a.extend({},c.DEFAULTS,d),this.transitioning=null,this.options.parent&&(this.$parent=a(this.options.parent)),this.options.toggle&&this.toggle()};c.VERSION="3.2.0",c.DEFAULTS={toggle:!0},c.prototype.dimension=function(){var a=this.$element.hasClass("width");return a?"width":"height"},c.prototype.show=function(){if(!this.transitioning&&!this.$element.hasClass("in")){var c=a.Event("show.bs.collapse");if(this.$element.trigger(c),!c.isDefaultPrevented()){var d=this.$parent&&this.$parent.find("> .panel > .in");if(d&&d.length){var e=d.data("bs.collapse");if(e&&e.transitioning)return;b.call(d,"hide"),e||d.data("bs.collapse",null)}var f=this.dimension();this.$element.removeClass("collapse").addClass("collapsing")[f](0),this.transitioning=1;var g=function(){this.$element.removeClass("collapsing").addClass("collapse in")[f](""),this.transitioning=0,this.$element.trigger("shown.bs.collapse")};if(!a.support.transition)return g.call(this);var h=a.camelCase(["scroll",f].join("-"));this.$element.one("bsTransitionEnd",a.proxy(g,this)).emulateTransitionEnd(350)[f](this.$element[0][h])}}},c.prototype.hide=function(){if(!this.transitioning&&this.$element.hasClass("in")){var b=a.Event("hide.bs.collapse");if(this.$element.trigger(b),!b.isDefaultPrevented()){var c=this.dimension();this.$element[c](this.$element[c]())[0].offsetHeight,this.$element.addClass("collapsing").removeClass("collapse").removeClass("in"),this.transitioning=1;var d=function(){this.transitioning=0,this.$element.trigger("hidden.bs.collapse").removeClass("collapsing").addClass("collapse")};return a.support.transition?void this.$element[c](0).one("bsTransitionEnd",a.proxy(d,this)).emulateTransitionEnd(350):d.call(this)}}},c.prototype.toggle=function(){this[this.$element.hasClass("in")?"hide":"show"]()};var d=a.fn.collapse;a.fn.collapse=b,a.fn.collapse.Constructor=c,a.fn.collapse.noConflict=function(){return a.fn.collapse=d,this},a(document).on("click.bs.collapse.data-api",'[data-toggle="collapse"]',function(c){var d,e=a(this),f=e.attr("data-target")||c.preventDefault()||(d=e.attr("href"))&&d.replace(/.*(?=#[^\s]+$)/,""),g=a(f),h=g.data("bs.collapse"),i=h?"toggle":e.data(),j=e.attr("data-parent"),k=j&&a(j);h&&h.transitioning||(k&&k.find('[data-toggle="collapse"][data-parent="'+j+'"]').not(e).addClass("collapsed"),e[g.hasClass("in")?"addClass":"removeClass"]("collapsed")),b.call(g,i)})}(jQuery),+function(a){"use strict";function b(b){b&&3===b.which||(a(e).remove(),a(f).each(function(){var d=c(a(this)),e={relatedTarget:this};d.hasClass("open")&&(d.trigger(b=a.Event("hide.bs.dropdown",e)),b.isDefaultPrevented()||d.removeClass("open").trigger("hidden.bs.dropdown",e))}))}function c(b){var c=b.attr("data-target");c||(c=b.attr("href"),c=c&&/#[A-Za-z]/.test(c)&&c.replace(/.*(?=#[^\s]*$)/,""));var d=c&&a(c);return d&&d.length?d:b.parent()}function d(b){return this.each(function(){var c=a(this),d=c.data("bs.dropdown");d||c.data("bs.dropdown",d=new g(this)),"string"==typeof b&&d[b].call(c)})}var e=".dropdown-backdrop",f='[data-toggle="dropdown"]',g=function(b){a(b).on("click.bs.dropdown",this.toggle)};g.VERSION="3.2.0",g.prototype.toggle=function(d){var e=a(this);if(!e.is(".disabled, :disabled")){var f=c(e),g=f.hasClass("open");if(b(),!g){"ontouchstart"in document.documentElement&&!f.closest(".navbar-nav").length&&a('
').insertAfter(a(this)).on("click",b);var h={relatedTarget:this};if(f.trigger(d=a.Event("show.bs.dropdown",h)),d.isDefaultPrevented())return;e.trigger("focus"),f.toggleClass("open").trigger("shown.bs.dropdown",h)}return!1}},g.prototype.keydown=function(b){if(/(38|40|27)/.test(b.keyCode)){var d=a(this);if(b.preventDefault(),b.stopPropagation(),!d.is(".disabled, :disabled")){var e=c(d),g=e.hasClass("open");if(!g||g&&27==b.keyCode)return 27==b.which&&e.find(f).trigger("focus"),d.trigger("click");var h=" li:not(.divider):visible a",i=e.find('[role="menu"]'+h+', [role="listbox"]'+h);if(i.length){var j=i.index(i.filter(":focus"));38==b.keyCode&&j>0&&j--,40==b.keyCode&&jGleez Smtpd is an email service; it will accept email for any email 2 | address and make it available to view without a password.
3 | 4 |To view email for a particular address, enter the username portion 5 | of the address into the box on the upper right and click go.
6 | 7 |This message can be customized by editing greeting.html. Change the
8 | configuration option greeting.file
if you'd like to move it
9 | outside of the Smtpd installation directory.