├── .gitignore ├── CHANGELOG ├── LICENSE ├── README.md ├── TODO.md ├── Taskfile.yml ├── api └── api.go ├── cli ├── alias.go ├── cli.go ├── dkim.go ├── mailbox.go ├── queue.go ├── rcpthost.go ├── relayip.go ├── routes.go ├── user.go └── utils.go ├── core ├── alias.go ├── clamav.go ├── config.go ├── database.go ├── deliverd.go ├── deliverd_auth.go ├── deliverd_delivery.go ├── deliverd_handler.go ├── deliverd_local.go ├── deliverd_remote.go ├── deliverd_route.go ├── dkim.go ├── errors.go ├── file_formatter.go ├── local.go ├── logger.go ├── mailbox.go ├── mailqueue.go ├── plugin.go ├── rcpthost.go ├── scope.go ├── smtp_client.go ├── smtpd.go ├── smtpd_dsn.go ├── smtpd_relay_ip.go ├── smtpd_session.go ├── smtpd_user.go ├── smtproutes.go ├── store.go ├── store_disk.go ├── store_openstack.go ├── tls.go ├── user.go ├── utils.go └── uuid.go ├── dist ├── conf │ └── tmail.cfg.base ├── run ├── ssl │ ├── server.crt │ ├── server.csr │ ├── server.key │ ├── web_server.crt │ └── web_server.key └── tpl │ └── bounce.tpl ├── doc ├── portBinding.txt ├── rfc1869.pdf ├── rfc1869.txt ├── rfc1870.pdf ├── rfc1870.txt ├── rfc5248.pdf ├── rfc5248.txt ├── rfc5321.pdf ├── rfc5321.txt └── rfc82821.txt ├── go.mod ├── go.sum ├── message ├── envelope.go ├── errors.go ├── message.go ├── message_test.go └── raw.go ├── plugin_import.go ├── plugins └── connect │ └── main.go ├── rest ├── auth.go ├── auth_test.go ├── handlers_queue.go ├── handlers_test.go ├── handlers_users.go ├── logger.go ├── logger_test.go ├── middleware_logger.go ├── middleware_logger_test.go ├── server.go ├── utils.go └── utils_test.go └── tmail.go /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | /archives/ 3 | /dist/tmail 4 | /dist/db/* 5 | dist/tools/easyCert/ 6 | dist/nsq/* 7 | dist/conf/tmail.cfg 8 | run 9 | test 10 | watch 11 | build 12 | buildDist 13 | dist/log/ 14 | dist/mailboxes/ 15 | dist/tmail64 16 | dist/tools/ 17 | tmail.zip 18 | 19 | ### golang 20 | vendor/**/**/ 21 | 22 | dist/tmail-2019-03-17 23 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | 2 | v 0.13 3 | - disable bolt init in CLI mode 4 | - deliverd: configurable timeout on deliverd-remote 5 | - deliverd remote: add cache for not responding host 6 | - microservices: hook before queuing -> allows to change enveloppe 7 | - microservices: deliverd telemetry 8 | - microservices: smtpd telemetry 9 | - deliverd: add specific queue life time for bounces 10 | - microservices: get routes via ms 11 | - microservices: check smtp relayok 12 | 13 | 14 | v 0.12 15 | - bugfix: limit retry interval to 1 Hour 16 | - improve recover in smtpd 17 | 18 | V 0.11 19 | - bugfix: "key" for queued message 20 | 21 | V 0.10 22 | - full RFC 5321 compliance 23 | - new SMTP client for remote deliveries 24 | - new store backend: openstack open storage 25 | - alias 26 | - 27 | 28 | V 0.9 29 | - Better handling of smtpd TLS error 30 | - bugfix: on RSET 31 | - bugfix: second STARTTLS response (see RFC 3207 4.2) 32 | - Log; add queue ID 33 | - DKIM: per domain 34 | - DKIM: cli tool 35 | - Webservices: poc 36 | 37 | V 0.8 38 | - DKIM: sign outgoing message (test) 39 | 40 | V 0.7 41 | - BugFix message-id 42 | - REST /queue 43 | - Bugfix: race condition on deliverd 44 | - Add concurrency limits on smtpd 45 | 46 | V 0.6 47 | - REST API base 48 | - REST API /users 49 | 50 | V 0.5 51 | - Log to file 52 | - Improve log 53 | - Case sensitivity on local part 54 | - add RSET and NOOP SMTP verbs 55 | 56 | V 0.4 57 | - Local deliveries 58 | - Dovecot support 59 | 60 | v 0.3 61 | - Bugfix: MySQL Datetime to Golang time.Time 62 | - BugFix: Optimise/fix queries for Mysql 63 | 64 | v 0.2 65 | - add Clamav support: http://tmail.io/doc/filtrage-smtp-antivirus-clamav/ 66 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Stéphane Depierrepont 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tmail 2 | 3 | [![Join the chat at https://gitter.im/toorop/tmail](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/toorop/tmail?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 4 | 5 | tmail is a SMTP server 6 | 7 | ## Features 8 | 9 | * SMTP, SMTP over SSL, ESMTP (SIZE, AUTH PLAIN, STARTTLS) 10 | * Advanced routing for outgoing mails (failover and round robin on routes, route by recipient, sender, authuser... ) 11 | * SMTPAUTH (plain & cram-md5) for in/outgoing mails 12 | * STARTTLS/SSL for in/outgoing connexions. 13 | * Manageable via CLI or REST API. 14 | * DKIM support for signing outgoing mails. 15 | * Builtin support of clamav (open-source antivirus scanner). 16 | * Builtin Dovecot (imap server) support. 17 | * Fully extendable via plugins 18 | * Easy to deploy 19 | * No dependencies: -> you do not have to install nor maintain libs 20 | * Clusterisable (todo) 21 | * IPV6 (soon) 22 | 23 | 24 | ## Quick install on linux (Ubuntu) 25 | 26 | For french users see: http://tmail.io/doc/installer-tmail/ 27 | 28 | ### add user tmail 29 | 30 | adduser tmail 31 | 32 | ### Fetch tmail dist 33 | 34 | # su tmail 35 | $ cd 36 | $ wget ftp://ftp.toorop.fr/softs/tmail/tmail.zip 37 | $ unzip tmail.zip 38 | $ cd dist 39 | 40 | Under dist you will find: 41 | 42 | * conf: configuration. 43 | * run: script used to launch tmail 44 | * ssl: is the place to store SSL cert. For testing purpose you can use those included. 45 | * tmail: tmail binary 46 | * tpl: text templates. 47 | * db: if you use sqlite as DB backend (MySQL and Postgresql are also supported), sqlite file will be stored in this directory. 48 | * store: mainly used to store raw email when they are in queue. (others kind of backend/storage engine are coming) 49 | * mailboxes: where mailboxes are stored if you activate Dovecot support. 50 | 51 | Make run script and tmail runnable: 52 | 53 | chmod 700 run tmail 54 | 55 | add directories: 56 | 57 | mkdir db 58 | mkdir store 59 | 60 | 61 | if you want to enable Dovecot support add mailboxes directory: 62 | 63 | mkdir mailboxes 64 | 65 | See [Enabling Dovecot support for tmail (french)](http://tmail.io/doc/mailboxes/) for more info. 66 | 67 | 68 | ### Configuration 69 | 70 | Init you conf file: 71 | 72 | cd conf 73 | cp tmail.cfg.base tmail.cfg 74 | chmod 600 tmail.cfg 75 | 76 | * TMAIL_ME: Hostname of the SMTP server (will be used for HELO|EHLO) 77 | 78 | * TMAIL_DB_DRIVER: I recommend sqlite3 unless you want to enable clustering (or you have a lot of domains/mailboxes) 79 | 80 | * TMAIL_SMTPD_DSNS: listening IP(s), port(s) and SSL options (see conf file for more info) 81 | 82 | * TMAIL_DELIVERD_LOCAL_IPS: IP(s) to use for sending mail to remote host. 83 | 84 | * TMAIL_SMTPD_CONCURRENCY_INCOMING: max concurent incomming proccess 85 | 86 | * TMAIL_DELIVERD_MAX_IN_FLIGHT: concurrent delivery proccess 87 | 88 | 89 | ### Init database 90 | 91 | tmail@dev:~/dist$ ./run 92 | Database 'driver: sqlite3, source: /home/tmail/dist/db/tmail.db' misses some tables. 93 | Should i create them ? (y/n): y 94 | 95 | [dev.tmail.io - 127.0.0.1] 2015/02/02 12:42:32.449597 INFO - smtpd 151.80.115.83:2525 launched. 96 | [dev.tmail.io - 127.0.0.1] 2015/02/02 12:42:32.449931 INFO - smtpd 151.80.115.83:5877 launched. 97 | [dev.tmail.io - 127.0.0.1] 2015/02/02 12:42:32.450011 INFO - smtpd 151.80.115.83:4655 SSL launched. 98 | [dev.tmail.io - 127.0.0.1] 2015/02/02 12:42:32.499728 INFO - deliverd launched 99 | 100 | ### Port forwarding 101 | 102 | As you run tmail under tmail user, it can't open port under 1024 (and for now tmail can be launched as root, open port under 25 and fork itself to unprivilegied user). 103 | 104 | The workaround is to use iptables to forward ports. 105 | For example, if we have tmail listening on ports 2525, and 5877 and we want tu use 25 and 587 as public ports, we have to use those iptables rules: 106 | 107 | iptables -t nat -A PREROUTING -p tcp --dport 25 -j REDIRECT --to-port 2525 108 | iptables -t nat -A PREROUTING -p tcp --dport 587 -j REDIRECT --to-port 5877 109 | 110 | ### First test 111 | 112 | $ telnet dev.tmail.io 25 113 | Trying 151.80.115.83... 114 | Connected to dev.tmail.io. 115 | Escape character is '^]'. 116 | 220 tmail.io tmail ESMTP f22815e0988b8766b6fe69cbc73fb0d965754f60 117 | HELO toto 118 | 250 tmail.io 119 | MAIL FROM: toorop@tmail.io 120 | 250 ok 121 | RCPT TO: toorop@tmail.io 122 | 554 5.7.1 : Relay access denied. 123 | Connection closed by foreign host. 124 | 125 | Perfect ! 126 | You got "Relay access denied" because by default noboby can use tmail for relaying mails. 127 | 128 | ### Relaying mails for @example.com 129 | 130 | If you want tmail to relay mails for example.com, just run: 131 | 132 | tmail rcpthost add example.com 133 | 134 | Note: If you have activated Dovecot support and example.com is a local domain, add -l flag : 135 | 136 | tmail rcpthost add -l example.com 137 | 138 | Does it work as expected ? 139 | 140 | $ telnet dev.tmail.io 25 141 | Trying 151.80.115.83... 142 | Connected to dev.tmail.io. 143 | Escape character is '^]'. 144 | 220 tmail.io tmail ESMTP 96b78ef8f850253cc956820a874e8ce40773bfb7 145 | HELO toto 146 | 250 tmail.io 147 | mail from: toorop@toorop.fr 148 | 250 ok 149 | rcpt to: toorop@example.com 150 | 250 ok 151 | data 152 | 354 End data with . 153 | subject: test tmail 154 | 155 | blabla 156 | . 157 | 250 2.0.0 Ok: queued 2736698d73c044fd7f1994e76814d737c702a25e 158 | quit 159 | 221 2.0.0 Bye 160 | Connection closed by foreign host. 161 | 162 | Yes ;) 163 | 164 | ### Allow relay from an IP 165 | 166 | tmail relayip add IP 167 | 168 | For example: 169 | 170 | tmail relayip add 127.0.0.1 171 | 172 | 173 | ### Basic routing 174 | 175 | By default tmail will use MX records for routing mails, but you can "manualy" configure alternative routing. 176 | If you want tmail to route mail from @example.com to mx.slowmail.com. It is as easy as adding this routing rule 177 | 178 | tmail routes add -d example.com -rh mx.slowmail.com 179 | 180 | You can find more elaborated routing rules on [tmail routing documentation (french)](http://tmail.io/doc/cli-gestion-route-smtp/) (translators are welcomed ;)) 181 | 182 | ### SMTP AUTH 183 | 184 | If you want to enable relaying after SMTP AUTH for user toorop@tmail.io, just enter: 185 | 186 | tmail user add -r toorop@tmail.io password 187 | 188 | 189 | If you want to delete user toorop@tmail.io : 190 | 191 | tmail user del toorop@tmail.io 192 | 193 | 194 | ### Let's Encrypt (TLS/SSL) 195 | 196 | If you want to activate TLS/SSL connections with a valid certificate (not an auto-signed one as it's by default) between mail clients and your tmail server you can get a let's Encrypt certificate, you have first to install let's Encrypt : 197 | 198 | cd ~ 199 | git clone https://github.com/letsencrypt/letsencrypt 200 | cd letsencrypt 201 | 202 | Then you can request a certificate 203 | 204 | ./letsencrypt-auto certonly --standalone -d your.hostname 205 | 206 | You'll have to provide a valid mail address and agree to the Let's Encrypt Term of Service. When certificate is issued you have to copy some files to the ssl/ directory 207 | 208 | cd /home/tmail/dist/ssl 209 | cp /etc/letsencrypt/live/your.hostname/fullchain.pem server.crt 210 | cp /etc/letsencrypt/live/your.hostname/privkey.pem server.key 211 | chown tmail.tmail server.* 212 | 213 | And it's done ! 214 | 215 | 216 | ## Contribute 217 | 218 | Feel free to inspect & improve tmail code, PR are welcomed ;) 219 | 220 | If you are not a coder, you can contribute too: 221 | 222 | * install and use tmail, I need feebacks. 223 | 224 | * as you can see reading this page, english is not my native language, so I need help to write english documentation. 225 | 226 | 227 | ## Roadmap 228 | 229 | * clustering 230 | * IPV6 231 | * write unit tests (yes i know...) 232 | * improve, refactor, optimize 233 | * test test test test 234 | 235 | 236 | ## License 237 | MIT, see LICENSE 238 | 239 | 240 | ## Imported packages 241 | 242 | github.com/nsqio/nsq/... 243 | github.com/codegangsta/cli 244 | github.com/codegangsta/negroni 245 | github.com/go-sql-driver/mysql 246 | github.com/jinzhu/gorm 247 | github.com/julienschmidt/httprouter 248 | github.com/kless/osutil/user/crypt/... 249 | github.com/lib/pq 250 | github.com/mattn/go-sqlite3 251 | github.com/nbio/httpcontext 252 | golang.org/x/crypto/bcrypt 253 | golang.org/x/crypto/blowfish 254 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | TODO 2 | - [ ] sync nsq/DB in case of crash (requeue in nsq expired messages from DB) 3 | - [ ] lot of things -------------------------------------------------------------------------------- /Taskfile.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | 3 | tasks: 4 | build: 5 | cmds: 6 | - go build -o dist/tmail 7 | 8 | run: 9 | deps: [build] 10 | dir: dist 11 | cmds: 12 | - source conf/tmail.cfg && ./tmail 13 | 14 | builddist: 15 | deps: [build] 16 | cmds: 17 | - zip -r tmail.zip dist/conf/tmail.cfg.base 18 | - zip -r tmail.zip dist/ssl 19 | - zip -r tmail.zip dist/tpl 20 | - zip -r tmail.zip dist/run 21 | - zip -r tmail.zip dist/tmail 22 | 23 | deploy: 24 | deps: [build] 25 | cmds: 26 | - rsync dist/tmail root@51.15.212.212:/home/tmail/dist/tmail 27 | - ssh root@51.15.212.212 setcap cap_net_bind_service=+ep /home/tmail/dist/tmail 28 | - ssh root@51.15.212.212 systemctl restart tmail -------------------------------------------------------------------------------- /api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | // WARNING core.ScopeBootstrap() must be called 4 | // WARNING 2: useless to be removed 5 | 6 | import ( 7 | "fmt" 8 | "log" 9 | 10 | "github.com/toorop/tmail/core" 11 | ) 12 | 13 | // USER 14 | 15 | // UserGetByLogin returns an User by his login 16 | func UserGetByLogin(login string) (user *core.User, err error) { 17 | return core.UserGetByLogin(login) 18 | } 19 | 20 | // UserAdd add a new user 21 | func UserAdd(login, passwd, mbQuota string, haveMailbox, authRelay, isCatchall bool) error { 22 | return core.UserAdd(login, passwd, mbQuota, haveMailbox, authRelay, isCatchall) 23 | } 24 | 25 | // UserDel delete an user (keep his mailboxe) 26 | func UserDel(login string) error { 27 | return core.UserDel(login) 28 | } 29 | 30 | // UserGetAll return all users 31 | func UserGetAll() (users []core.User, err error) { 32 | return core.UserList() 33 | } 34 | 35 | // UserChangePassword is used to change user password 36 | func UserChangePassword(login, password string) error { 37 | return core.UserChangePassword(login, password) 38 | } 39 | 40 | // ALIAS 41 | 42 | // AliasAdd add an alias 43 | func AliasAdd(alias, deliverTo, pipe string, isMinilist bool) error { 44 | return core.AliasAdd(alias, deliverTo, pipe, isMinilist) 45 | } 46 | 47 | // AliasDel delete an alias 48 | func AliasDel(alias string) error { 49 | return core.AliasDel(alias) 50 | } 51 | 52 | // AliasList return all alias 53 | func AliasList() (aliases []core.Alias, err error) { 54 | return core.AliasList() 55 | } 56 | 57 | /* 58 | // MAILBOXES 59 | 60 | // MailboxAdd create a new mailbox 61 | func MailboxAdd(mailbox string) error { 62 | return core.MailboxAdd(mailbox) 63 | } 64 | 65 | // MailboxDel delete a mailbox 66 | func MailboxDel(mailbox string) error { 67 | return core.MailboxDel(mailbox) 68 | } 69 | 70 | // MailboxList return all mailboxes 71 | func MailboxList() (mailboxes []core.Mailbox, err error) { 72 | return core.MailboxList() 73 | } 74 | */ 75 | 76 | // RELAY IP 77 | // RelayIpAdd add an IP authozed to relay through tmail 78 | func RelayIpAdd(ip string) error { 79 | return core.RelayIpAdd(ip) 80 | } 81 | 82 | // RelayIpDel remove an ip from authorized IP 83 | func RelayIpDel(ip string) error { 84 | return core.RelayIpDel(ip) 85 | } 86 | 87 | // RelayIpGetAll returns all IPs which are authorized to relay through tmail 88 | func RelayIpGetAll() (ips []core.RelayIpOk, err error) { 89 | return core.RelayIpGetAll() 90 | } 91 | 92 | // Queue 93 | // QueueGetMessages returns all message in queue 94 | func QueueGetMessages() ([]core.QMessage, error) { 95 | return core.QueueListMessages() 96 | } 97 | 98 | // QueueCount returns number of messages in queue 99 | func QueueCount() (uint32, error) { 100 | return core.QueueCount() 101 | } 102 | 103 | // QueueGetMessage return a message by its id 104 | func QueueGetMessage(id int64) (core.QMessage, error) { 105 | return core.QueueGetMessageById(id) 106 | } 107 | 108 | // QueueDiscardMsgByKey discard a message (delete without bouncing) by his id 109 | func QueueDiscardMsg(id int64) error { 110 | m, err := core.QueueGetMessageById(id) 111 | if err != nil { 112 | return err 113 | } 114 | return m.Discard() 115 | } 116 | 117 | // QueueBounceMsgByKey bounce a message by his key 118 | func QueueBounceMsg(id int64) error { 119 | m, err := core.QueueGetMessageById(id) 120 | if err != nil { 121 | return err 122 | } 123 | return m.Bounce() 124 | } 125 | 126 | // QueuePurge delete expired message 127 | // WARNING use at your own risks... 128 | func QueuePurge() error { 129 | // get expired message 130 | messages, err := core.QueueGetExpiredMessages() 131 | if err != nil { 132 | return err 133 | } 134 | for _, m := range messages { 135 | log.Println(fmt.Sprintf("Deleting %s", m.Uuid)) 136 | if err = m.Delete(); err != nil { 137 | return err 138 | } 139 | } 140 | return nil 141 | } 142 | 143 | // ROUTES 144 | // RoutesGet returns all routes 145 | func RoutesGet() ([]core.Route, error) { 146 | return core.GetAllRoutes() 147 | } 148 | 149 | // RoutesAdd adds en new route 150 | func RoutesAdd(host, localIp, remoteHost string, remotePort, priority int, user, mailFrom, smtpAuthLogin, smtpAuthPasswd string) error { 151 | return core.AddRoute(host, localIp, remoteHost, remotePort, priority, user, mailFrom, smtpAuthLogin, smtpAuthPasswd) 152 | } 153 | 154 | // RoutesDel delete route routeId 155 | func RoutesDel(routeId int64) error { 156 | return core.DelRoute(routeId) 157 | } 158 | 159 | // RCPTHOSTS ie locals domains 160 | 161 | // RcptHostAdd add a rcpthost 162 | func RcpthostAdd(host string, isLocal, isAlias bool) error { 163 | return core.RcpthostAdd(host, isLocal, isAlias) 164 | } 165 | 166 | // RcpthostDel delete a rcpthost 167 | func RcpthostDel(host string) error { 168 | return core.RcpthostDel(host) 169 | } 170 | 171 | // RcpthostList returns all rcpthosts 172 | func RcpthostList() (hosts []core.RcptHost, err error) { 173 | return core.RcpthostGetAll() 174 | } 175 | 176 | // DKIM 177 | 178 | // DkimEnable Enable DKIM for domain domain 179 | // DkimEnable will create keys pair 180 | func DkimEnable(domain string) (dkimConfig *core.DkimConfig, err error) { 181 | return core.DkimEnable(domain) 182 | } 183 | 184 | // DkimDisable will remove DKIM config for domain domain from DB 185 | // resulting in desactivate DKIM for outgoing message from the domain. 186 | func DkimDisable(domain string) error { 187 | return core.DkimDisable(domain) 188 | } 189 | 190 | // DkimGetConfig return DKIM configuration for domain domain 191 | func DkimGetConfig(domain string) (dkimConfig *core.DkimConfig, err error) { 192 | return core.DkimGetConfig(domain) 193 | } 194 | -------------------------------------------------------------------------------- /cli/alias.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/toorop/tmail/api" 7 | cgCli "github.com/urfave/cli" 8 | ) 9 | 10 | var alias = cgCli.Command{ 11 | Name: "alias", 12 | Usage: "commands to manage aliases", 13 | Subcommands: []cgCli.Command{ 14 | // users 15 | { 16 | Name: "add", 17 | Usage: "Add an alias", 18 | Description: "tmail alias add [--pipe COMMAND] [--deliver-to REAL_LOCAL_USER] ALIAS ", 19 | Flags: []cgCli.Flag{ 20 | cgCli.StringFlag{ 21 | Name: "pipe, p", 22 | Usage: "mail is piped to command. (eg cat mail | /path/to/cmd)", 23 | }, 24 | cgCli.StringFlag{ 25 | Name: "deliver-to, d", 26 | Usage: "in --deliver-to user@local_domain1, mail will be deliverer to local1@domain", 27 | }, 28 | cgCli.BoolFlag{ 29 | Name: "minilist, m", 30 | Usage: "if set, enveloppe mail from is rewritted to alias@domain", 31 | }, 32 | }, 33 | Action: func(c *cgCli.Context) { 34 | if len(c.Args()) != 1 { 35 | cliDieBadArgs(c) 36 | } 37 | err := api.AliasAdd(c.Args()[0], c.String("d"), c.String("p"), c.Bool("m")) 38 | cliHandleErr(err) 39 | cliDieOk() 40 | }, 41 | }, { 42 | Name: "del", 43 | Usage: "Delete an alias", 44 | Description: "tmail alias del ALIAS", 45 | Action: func(c *cgCli.Context) { 46 | if len(c.Args()) != 1 { 47 | cliDieBadArgs(c) 48 | } 49 | err := api.AliasDel(c.Args()[0]) 50 | cliHandleErr(err) 51 | cliDieOk() 52 | }, 53 | }, { 54 | Name: "list", 55 | Usage: "list all aliases", 56 | Description: "tmail alias list", 57 | Action: func(c *cgCli.Context) { 58 | aliases, err := api.AliasList() 59 | cliHandleErr(err) 60 | if len(aliases) == 0 { 61 | println("there is no alias defined") 62 | } else { 63 | for _, alias := range aliases { 64 | println(alias.Alias) 65 | if alias.Pipe != "" { 66 | println("\tPipe: " + alias.Pipe) 67 | } 68 | for _, d := range strings.Split(alias.DeliverTo, ";") { 69 | println("\t-> " + d) 70 | } 71 | println(" ") 72 | } 73 | } 74 | cliDieOk() 75 | }, 76 | }, 77 | }, 78 | } 79 | -------------------------------------------------------------------------------- /cli/cli.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | cgCli "github.com/urfave/cli" 5 | ) 6 | 7 | // CliCommands is a slice of subcomands 8 | var CliCommands = []cgCli.Command{ 9 | alias, 10 | Queue, 11 | Routes, 12 | user, 13 | Rcpthost, 14 | RelayIP, 15 | //Mailbox, 16 | Dkim, 17 | } 18 | 19 | var cliCommandHelpTemplate = `NAME: 20 | {{.Name}} - {{.Description}} 21 | USAGE: 22 | command {{.Name}}{{if .Flags}} [command options]{{end}} [arguments...]{{if .Description}} 23 | DESCRIPTION: 24 | {{.Description}}{{end}}{{if .Flags}} 25 | OPTIONS: 26 | {{range .Flags}}{{.}} 27 | {{end}}{{ end }} 28 | ` 29 | -------------------------------------------------------------------------------- /cli/dkim.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/toorop/tmail/api" 7 | cgCli "github.com/urfave/cli" 8 | ) 9 | 10 | var Dkim = cgCli.Command{ 11 | 12 | Name: "dkim", 13 | Usage: "Commands to manage DKIM", 14 | //Usage: "tmail dkim [arguments...]", 15 | Subcommands: []cgCli.Command{ // Add a mailbox 16 | { 17 | Name: "enable", 18 | Usage: "Activate DKIM on domain DOMAIN", 19 | Description: "To enable DKIM on domain DOMAIN:\n\ttmail dkim enable DOMAIN", 20 | Action: func(c *cgCli.Context) { 21 | if len(c.Args()) != 1 { 22 | cliDieBadArgs(c) 23 | } 24 | dkc, err := api.DkimEnable(c.Args().First()) 25 | cliHandleErr(err) 26 | println("Done !") 27 | fmt.Printf("It remains for you to create this TXT record on %s._domainkey.%s zone:\n\nv=DKIM1;k=rsa;s=email;h=sha256;p=%s\n\n", dkc.Selector, c.Args().First(), dkc.PubKey) 28 | println("And... That's all.") 29 | 30 | cliDieOk() 31 | }, 32 | }, { 33 | Name: "disable", 34 | Usage: "Disable DKIM on domain DOMAIN", 35 | Description: "TO disable DKIM on domain DOMAIN\n\ttmail dkim disable DOMAIN", 36 | Action: func(c *cgCli.Context) { 37 | if len(c.Args()) != 1 { 38 | cliDieBadArgs(c) 39 | } 40 | err := api.DkimDisable(c.Args().First()) 41 | cliHandleErr(err) 42 | cliDieOk() 43 | }, 44 | }, { 45 | Name: "getprivkey", 46 | Usage: "Return the private key of domain DOMAIN", 47 | Description: "tmail dkim getprivkey DOMAIN", 48 | Action: func(c *cgCli.Context) { 49 | if len(c.Args()) != 1 { 50 | cliDieBadArgs(c) 51 | } 52 | domain := c.Args().First() 53 | dkc, err := api.DkimGetConfig(domain) 54 | cliHandleErr(err) 55 | if dkc != nil { 56 | println(dkc.PrivKey) 57 | } else { 58 | println("DKIM is not enabled for " + domain) 59 | println("To enable DKIM on " + domain + " run command:") 60 | println("tmail dkim enable " + domain) 61 | } 62 | 63 | cliDieOk() 64 | }, 65 | }, { 66 | Name: "getpubkey", 67 | Usage: "Return the public key of domain DOMAIN", 68 | Description: "tmail dkim getpubkey DOMAIN", 69 | Action: func(c *cgCli.Context) { 70 | if len(c.Args()) != 1 { 71 | cliDieBadArgs(c) 72 | } 73 | domain := c.Args().First() 74 | dkc, err := api.DkimGetConfig(domain) 75 | cliHandleErr(err) 76 | if dkc != nil { 77 | println(dkc.PubKey) 78 | } else { 79 | println("DKIM is not enabled for " + domain) 80 | println("To enable DKIM on " + domain + " run command:") 81 | println("tmail dkim enable " + domain) 82 | } 83 | 84 | cliDieOk() 85 | }, 86 | }, { 87 | Name: "getdnsrecord", 88 | Usage: "Return the DKIM DNS TXT record for domain DOMAIN", 89 | Description: "tmail dkim getdnsrecord DOMAIN", 90 | Action: func(c *cgCli.Context) { 91 | if len(c.Args()) != 1 { 92 | cliDieBadArgs(c) 93 | } 94 | domain := c.Args().First() 95 | dkc, err := api.DkimGetConfig(domain) 96 | cliHandleErr(err) 97 | if dkc != nil { 98 | fmt.Printf("%s._domainkey.%s zone:\n\nv=DKIM1;k=rsa;s=email;h=sha256;p=%s\n\n", domain, dkc.Selector, dkc.PubKey) 99 | } else { 100 | println("DKIM is not enabled for " + domain) 101 | println("To enable DKIM on " + domain + " run command:") 102 | println("tmail dkim enable " + domain) 103 | } 104 | 105 | cliDieOk() 106 | }, 107 | }, 108 | }, 109 | } 110 | -------------------------------------------------------------------------------- /cli/mailbox.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | /* 4 | import ( 5 | "fmt" 6 | "github.com/toorop/tmail/api" 7 | cgCli "github.com/codegangsta/cli" 8 | ) 9 | 10 | var Mailbox = cgCli.Command{ 11 | Name: "mailbox", 12 | Usage: "commands to manage mailboxes", 13 | Subcommands: []cgCli.Command{ 14 | // Add a mailbox 15 | { 16 | Name: "add", 17 | Usage: "Add a mailbox", 18 | Description: "tmail mailbox add MAILBOX", 19 | Action: func(c *cgCli.Context) { 20 | if len(c.Args()) == 0 { 21 | cliDieBadArgs(c) 22 | } 23 | err := api.MailboxAdd(c.Args().First()) 24 | cliHandleErr(err) 25 | }, 26 | }, 27 | // List Mailboxes 28 | { 29 | Name: "list", 30 | Usage: "List mailboxes", 31 | Description: "tmail mailbox list [-d domain]", 32 | Action: func(c *cgCli.Context) { 33 | mailboxes, err := api.MailboxList() 34 | cliHandleErr(err) 35 | if len(mailboxes) == 0 { 36 | println("There no mailboxes yet.") 37 | } else { 38 | for _, mailbox := range mailboxes { 39 | line := fmt.Sprintf("%d %s@%s", mailbox.Id, mailbox.LocalPart, mailbox.DomainPart) 40 | fmt.Println(line) 41 | } 42 | } 43 | }, 44 | }, 45 | // Delete a mailbox 46 | { 47 | Name: "del", 48 | Usage: "Delete a mailbox", 49 | Description: "tmail mailbox delete MAILBOX", 50 | Action: func(c *cgCli.Context) { 51 | if len(c.Args()) == 0 { 52 | cliDieBadArgs(c) 53 | } 54 | err := api.MailboxDel(c.Args().First()) 55 | cliHandleErr(err) 56 | }, 57 | }, 58 | }, 59 | }*/ 60 | -------------------------------------------------------------------------------- /cli/queue.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strconv" 7 | 8 | "github.com/toorop/tmail/api" 9 | cgCli "github.com/urfave/cli" 10 | ) 11 | 12 | var Queue = cgCli.Command{ 13 | Name: "queue", 14 | Usage: "commands to interact with tmail queue", 15 | Subcommands: []cgCli.Command{ 16 | // list queue 17 | { 18 | Name: "list", 19 | Usage: "List messages in queue", 20 | Description: "tmail queue list", 21 | Action: func(c *cgCli.Context) { 22 | var status string 23 | messages, err := api.QueueGetMessages() 24 | cliHandleErr(err) 25 | if len(messages) == 0 { 26 | println("There is no message in queue.") 27 | } else { 28 | fmt.Printf("%d messages in queue.\r\n", len(messages)) 29 | for _, m := range messages { 30 | switch m.Status { 31 | case 0: 32 | status = "Delivery in progress" 33 | case 1: 34 | status = "Will be discarded" 35 | case 2: 36 | status = "Scheduled" 37 | case 3: 38 | status = "Will be bounced" 39 | } 40 | 41 | msg := fmt.Sprintf("%d - From: %s - To: %s - Status: %s - Added: %v ", m.Id, m.MailFrom, m.RcptTo, status, m.AddedAt) 42 | if m.Status != 0 { 43 | msg += fmt.Sprintf("- Next delivery process scheduled at: %v", m.NextDeliveryScheduledAt) 44 | } 45 | println(msg) 46 | } 47 | } 48 | os.Exit(0) 49 | }, 50 | }, { 51 | Name: "count", 52 | Usage: "count messages in queue", 53 | Description: "tmail queue count", 54 | Action: func(c *cgCli.Context) { 55 | count, err := api.QueueCount() 56 | cliHandleErr(err) 57 | println(count) 58 | os.Exit(0) 59 | }, 60 | }, 61 | { 62 | Name: "discard", 63 | Usage: "Discard (delete without bouncing) a message in queue", 64 | Description: "tmail queue discard MESSAGE_ID", 65 | Action: func(c *cgCli.Context) { 66 | if len(c.Args()) != 1 { 67 | cliDieBadArgs(c) 68 | } 69 | id, err := strconv.ParseInt(c.Args()[0], 10, 64) 70 | cliHandleErr(err) 71 | cliHandleErr(api.QueueDiscardMsg(id)) 72 | cliDieOk() 73 | }, 74 | }, 75 | { 76 | Name: "bounce", 77 | Usage: "Bounce a message in queue", 78 | Description: "tmail queue bounce MESSAGE_ID", 79 | Action: func(c *cgCli.Context) { 80 | if len(c.Args()) != 1 { 81 | cliDieBadArgs(c) 82 | } 83 | id, err := strconv.ParseInt(c.Args()[0], 10, 64) 84 | cliHandleErr(err) 85 | cliHandleErr(api.QueueBounceMsg(id)) 86 | cliDieOk() 87 | }, 88 | }, 89 | { 90 | Name: "purge", 91 | Usage: "Purge expired message from queue", 92 | Description: "tmail queue purge", 93 | Action: func(c *cgCli.Context) { 94 | cliHandleErr(api.QueuePurge()) 95 | cliDieOk() 96 | }, 97 | }, 98 | }, 99 | } 100 | -------------------------------------------------------------------------------- /cli/rcpthost.go: -------------------------------------------------------------------------------- 1 | /* 2 | TODO: create postmaster account when add local rcpthost 3 | */ 4 | 5 | package cli 6 | 7 | import ( 8 | "fmt" 9 | 10 | "github.com/toorop/tmail/api" 11 | cgCli "github.com/urfave/cli" 12 | ) 13 | 14 | // Rcpthost represents commands for dealing with rcpthosts 15 | var Rcpthost = cgCli.Command{ 16 | Name: "rcpthost", 17 | Usage: "commands to manage domains that tmail should handle", 18 | Subcommands: []cgCli.Command{ 19 | { 20 | Name: "add", 21 | Usage: "Add a rcpthost", 22 | Description: "tmail rcpthost add HOSTNAME", 23 | Flags: []cgCli.Flag{ 24 | cgCli.BoolFlag{ 25 | Name: "local, l", 26 | Usage: "Set this flag if it's a remote host.", 27 | }, 28 | }, 29 | Action: func(c *cgCli.Context) { 30 | if len(c.Args()) == 0 { 31 | cliDieBadArgs(c) 32 | } 33 | err := api.RcpthostAdd(c.Args().First(), c.Bool("l"), false) 34 | cliHandleErr(err) 35 | }, 36 | }, 37 | // List rcpthosts 38 | { 39 | Name: "list", 40 | Usage: "List rcpthosts", 41 | Description: "tmail rcpthost list", 42 | Action: func(c *cgCli.Context) { 43 | rcpthosts, err := api.RcpthostList() 44 | cliHandleErr(err) 45 | if len(rcpthosts) == 0 { 46 | println("There no rcpthosts.") 47 | } else { 48 | for _, host := range rcpthosts { 49 | line := fmt.Sprintf("%d %s", host.Id, host.Hostname) 50 | if host.IsLocal { 51 | line += " local" 52 | } else { 53 | line += "remote" 54 | } 55 | fmt.Println(line) 56 | } 57 | } 58 | }, 59 | }, 60 | // Delete rcpthost 61 | { 62 | Name: "del", 63 | Usage: "Delete a rcpthost", 64 | Description: "tmail rcpthost del HOSTNAME", 65 | Action: func(c *cgCli.Context) { 66 | if len(c.Args()) == 0 { 67 | cliDieBadArgs(c) 68 | } 69 | err := api.RcpthostDel(c.Args().First()) 70 | cliHandleErr(err) 71 | }, 72 | }, 73 | }, 74 | } 75 | -------------------------------------------------------------------------------- /cli/relayip.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/toorop/tmail/api" 7 | cgCli "github.com/urfave/cli" 8 | ) 9 | 10 | var RelayIP = cgCli.Command{ 11 | Name: "relayip", 12 | Usage: "commands to authorise IP to relay through tmail", 13 | Subcommands: []cgCli.Command{ 14 | // Add an authorized IP 15 | { 16 | Name: "add", 17 | Usage: "Add an authorized IP", 18 | Description: "tmail relayip add IP", 19 | Action: func(c *cgCli.Context) { 20 | if len(c.Args()) == 0 { 21 | cliDieBadArgs(c) 22 | } 23 | cliHandleErr(api.RelayIpAdd(c.Args().First())) 24 | }, 25 | }, 26 | // List authorized IPs 27 | { 28 | Name: "list", 29 | Usage: "List authorized IP", 30 | Description: "tmail relayip list", 31 | Action: func(c *cgCli.Context) { 32 | ips, err := api.RelayIpGetAll() 33 | cliHandleErr(err) 34 | if len(ips) == 0 { 35 | println("There no athorized IP.") 36 | } else { 37 | for _, ip := range ips { 38 | fmt.Println(fmt.Sprintf("%d %s", ip.Id, ip.Ip)) 39 | } 40 | } 41 | }, 42 | }, 43 | 44 | // Delete relayip 45 | { 46 | Name: "del", 47 | Usage: "Delete an authorized IP", 48 | Description: "tmail relayip del IP", 49 | Action: func(c *cgCli.Context) { 50 | if len(c.Args()) == 0 { 51 | cliDieBadArgs(c) 52 | } 53 | err := api.RelayIpDel(c.Args().First()) 54 | cliHandleErr(err) 55 | }, 56 | }, 57 | }, 58 | } 59 | -------------------------------------------------------------------------------- /cli/routes.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strconv" 7 | 8 | "github.com/toorop/tmail/api" 9 | cgCli "github.com/urfave/cli" 10 | ) 11 | 12 | var Routes = cgCli.Command{ 13 | Name: "routes", 14 | Usage: "commands to manage outgoing SMTP routes", 15 | Subcommands: []cgCli.Command{ 16 | { 17 | Name: "list", 18 | Usage: "List routes", 19 | Description: "tmail routes list", 20 | Action: func(c *cgCli.Context) { 21 | routes, err := api.RoutesGet() 22 | cliHandleErr(err) 23 | //scope.Log.Debug(routes) 24 | if len(routes) == 0 { 25 | println("There is no routes configurated, all mails are routed following MX records") 26 | } else { 27 | for _, route := range routes { 28 | //scope.Log.Debug(route) 29 | 30 | // ID 31 | line := fmt.Sprintf("%d", route.Id) 32 | 33 | // Host 34 | line += " - Destination host: " + route.Host 35 | 36 | // If mail from 37 | if route.MailFrom.Valid && route.MailFrom.String != "" { 38 | line += " - if mail from: " + route.MailFrom.String 39 | } 40 | 41 | // Priority 42 | line += " - Prority: " 43 | if route.Priority.Valid && route.Priority.Int64 != 0 { 44 | line += fmt.Sprintf("%d", route.Priority.Int64) 45 | } else { 46 | line += "1" 47 | } 48 | 49 | // Local IPs 50 | line += " - Local IPs: " 51 | if route.LocalIp.Valid && route.LocalIp.String != "" { 52 | line += route.LocalIp.String 53 | } else { 54 | line += "default" 55 | } 56 | 57 | // Remote Host 58 | line += " - Remote host: " 59 | if route.SmtpAuthLogin.Valid && route.SmtpAuthLogin.String != "" { 60 | line += route.SmtpAuthLogin.String 61 | if route.SmtpAuthPasswd.Valid && route.SmtpAuthPasswd.String != "" { 62 | line += ":" + route.SmtpAuthPasswd.String 63 | } 64 | line += "@" 65 | } 66 | 67 | line += route.RemoteHost 68 | if route.RemotePort.Valid && route.RemotePort.Int64 != 0 { 69 | line += fmt.Sprintf(":%d", route.RemotePort.Int64) 70 | } else { 71 | line += ":25" 72 | } 73 | 74 | println(line) 75 | } 76 | } 77 | os.Exit(0) 78 | }, 79 | }, 80 | { 81 | Name: "add", 82 | Usage: "Add a route", 83 | Description: "tmail routes add -d DESTINATION_HOST -rh REMOTE_HOST [-rp REMOTE_PORT] [-p PRORITY] [-l LOCAL_IP] [-u AUTHENTIFIED_USER] [-f MAIL_FROM] [-rl REMOTE_LOGIN] [-rpwd REMOTE_PASSWD]", 84 | Flags: []cgCli.Flag{ 85 | cgCli.StringFlag{ 86 | Name: "destination, d", 87 | Value: "", 88 | Usage: "hostame destination, eg domain in rcpt user@domain", 89 | }, 90 | cgCli.StringFlag{ 91 | Name: "remote host, rh", 92 | Value: "", 93 | Usage: "remote host, eg where email should be deliver", 94 | }, cgCli.IntFlag{ 95 | Name: "remotePort, rp", 96 | Value: 25, 97 | Usage: "Route port", 98 | }, 99 | 100 | cgCli.IntFlag{ 101 | Name: "priority, p", 102 | Value: 1, 103 | Usage: "Route priority. Lowest-numbered priority routes are the most preferred", 104 | }, 105 | cgCli.StringFlag{ 106 | Name: "localIp, l", 107 | Value: "", 108 | Usage: "Local IP(s) to use. If you want to add multiple IP separate them by | for round-robin or & for failover. Don't mix & and |", 109 | }, 110 | cgCli.StringFlag{ 111 | Name: "smtpUser, u", 112 | Value: "", 113 | Usage: "Routes for authentified user user.", 114 | }, 115 | cgCli.StringFlag{ 116 | Name: "mailFrom, f", 117 | Value: "", 118 | Usage: "Routes for MAIL FROM. User need to be authentified", 119 | }, 120 | cgCli.StringFlag{ 121 | Name: "remoteLogin, rl", 122 | Value: "", 123 | Usage: "SMTPauth login for remote host", 124 | }, 125 | cgCli.StringFlag{ 126 | Name: "remotePasswd, rpwd", 127 | Value: "", 128 | Usage: "SMTPauth passwd for remote host", 129 | }, 130 | }, 131 | Action: func(c *cgCli.Context) { 132 | // si la destination n'est pas renseignée on wildcard 133 | host := c.String("d") 134 | if host == "" { 135 | host = "*" 136 | } 137 | // (host, localIp, remoteHost string, remotePort, priority int64, user, mailFrom, smtpAuthLogin, smtpAuthPasswd string) 138 | err := api.RoutesAdd(host, c.String("l"), c.String("rh"), c.Int("rp"), c.Int("p"), c.String("u"), c.String("f"), c.String("rl"), c.String("rpwd")) 139 | cliHandleErr(err) 140 | }, 141 | }, 142 | { 143 | Name: "del", 144 | Usage: "Delete a route", 145 | Description: "tmail routes del ROUTE_ID", 146 | Action: func(c *cgCli.Context) { 147 | if len(c.Args()) != 1 { 148 | cliDieBadArgs(c, "you must provide a route ID") 149 | } 150 | routeId, err := strconv.ParseInt(c.Args()[0], 10, 64) 151 | cliHandleErr(err) 152 | err = api.RoutesDel(routeId) 153 | cliHandleErr(err) 154 | }, 155 | }, 156 | }, 157 | } 158 | -------------------------------------------------------------------------------- /cli/user.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "github.com/toorop/tmail/api" 5 | cgCli "github.com/urfave/cli" 6 | ) 7 | 8 | var user = cgCli.Command{ 9 | Name: "user", 10 | Usage: "commands to manage users of mailserver", 11 | Subcommands: []cgCli.Command{ 12 | // users 13 | { 14 | Name: "add", 15 | Usage: "Add an user", 16 | Description: "tmail user add USER CLEAR_PASSWD [-m] [-r] [-q BYTES] [--catchall]", 17 | Flags: []cgCli.Flag{ 18 | cgCli.BoolFlag{ 19 | Name: "mailbox, m", 20 | Usage: "Create a mailbox for this user.", 21 | }, 22 | cgCli.BoolFlag{ 23 | Name: "relay, r", 24 | Usage: "Authorise user to use server as SMTP relay.", 25 | }, 26 | cgCli.BoolFlag{ 27 | Name: "catchall", 28 | Usage: "Set this user as catchall for domain", 29 | }, 30 | cgCli.StringFlag{ 31 | Name: "quota, q", 32 | Value: "", 33 | Usage: "Mailbox quota in bytes (not bits). You can use K,M,G as unit. Eg: 10G mean a quota of 10GB", 34 | }, 35 | }, 36 | Action: func(c *cgCli.Context) { 37 | var err error 38 | if len(c.Args()) < 2 { 39 | cliDieBadArgs(c) 40 | } 41 | err = api.UserAdd(c.Args()[0], c.Args()[1], c.String("q"), c.Bool("m"), c.Bool("r"), c.Bool("catchall")) 42 | cliHandleErr(err) 43 | cliDieOk() 44 | }, 45 | }, 46 | { 47 | Name: "del", 48 | Usage: "Delete an user", 49 | Description: "tmail user del USER", 50 | Action: func(c *cgCli.Context) { 51 | var err error 52 | if len(c.Args()) != 1 { 53 | cliDieBadArgs(c) 54 | } 55 | err = api.UserDel(c.Args()[0]) 56 | cliHandleErr(err) 57 | cliDieOk() 58 | }, 59 | }, 60 | // Update to change proprieties of an user 61 | // for now only password change is handled 62 | { 63 | Name: "update", 64 | Usage: "change proprieties of an user", 65 | Description: "tmail user update USER -p NEW_PASSWORD", 66 | Flags: []cgCli.Flag{ 67 | cgCli.StringFlag{ 68 | Name: "password, p", 69 | Usage: "update user password", 70 | }, 71 | }, 72 | Action: func(c *cgCli.Context) { 73 | if len(c.Args()) != 1 { 74 | cliDieBadArgs(c) 75 | } 76 | if c.String("p") != "" { 77 | cliHandleErr(api.UserChangePassword(c.Args()[0], c.String("p"))) 78 | cliDieOk() 79 | } 80 | cliDieBadArgs(c) 81 | }, 82 | }, 83 | { 84 | Name: "list", 85 | Usage: "Return a list of users", 86 | Description: "", 87 | Action: func(c *cgCli.Context) { 88 | users, err := api.UserGetAll() 89 | cliHandleErr(err) 90 | if len(users) == 0 { 91 | println("There is no users yet.") 92 | return 93 | } 94 | for _, user := range users { 95 | line := user.Login + " - authrelay: " 96 | if user.AuthRelay { 97 | line += "yes" 98 | } else { 99 | line += "no" 100 | } 101 | line += " - have mailbox: " 102 | if user.HaveMailbox { 103 | line += "yes - home: " + user.Home 104 | } else { 105 | line += "no" 106 | } 107 | if user.Active == "Y" { 108 | line += " - active: yes" 109 | } else { 110 | line += " - active: no" 111 | } 112 | if user.IsCatchall { 113 | line += " - catchall: yes" 114 | } else { 115 | line += " - catchall: no" 116 | } 117 | println(line) 118 | } 119 | }, 120 | }, 121 | }, 122 | } 123 | -------------------------------------------------------------------------------- /cli/utils.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "os" 5 | 6 | cgCli "github.com/urfave/cli" 7 | ) 8 | 9 | // gotError handle error from cli 10 | func cliHandleErr(err error) { 11 | if err != nil { 12 | println("Error: ", err.Error()) 13 | os.Exit(1) 14 | } 15 | } 16 | 17 | // cliDieBadArgs die on bad arg 18 | func cliDieBadArgs(c *cgCli.Context, msg ...string) { 19 | out := "" 20 | if len(msg) != 0 { 21 | out = msg[0] 22 | } else { 23 | out = "bad args" 24 | } 25 | println("Error: " + out) 26 | cgCli.ShowAppHelp(c) 27 | os.Exit(1) 28 | } 29 | 30 | func cliDieOk() { 31 | //println("Success") 32 | os.Exit(0) 33 | } 34 | -------------------------------------------------------------------------------- /core/alias.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "errors" 5 | "os/exec" 6 | "strings" 7 | 8 | "github.com/jinzhu/gorm" 9 | ) 10 | 11 | // Alias represents a tmail alias 12 | type Alias struct { 13 | ID int64 14 | Alias string `sql:"unique"` 15 | DeliverTo string `sql:"null"` 16 | Pipe string `sql:"null"` 17 | IsDomAlias bool `sql:"default:false"` 18 | IsMiniList bool `sql:"default:false"` 19 | } 20 | 21 | // AliasGet returns an alias 22 | func AliasGet(aliasStr string) (alias Alias, err error) { 23 | err = DB.Where("alias = ?", aliasStr).Find(&alias).Error 24 | return alias, err 25 | } 26 | 27 | // AliasAdd create a new tmail alias 28 | func AliasAdd(alias, deliverTo, pipe string, isMiniList bool) error { 29 | isDomAlias := false 30 | 31 | // deliverTo && pipe must be != null 32 | if deliverTo == "" && pipe == "" { 33 | return errors.New("you must define pipe command OR local mailbox(es), domain where mails for this alias have to be delivered") 34 | } 35 | alias = strings.ToLower(strings.TrimSpace(alias)) 36 | 37 | // domain or adress alias 38 | localDom := strings.SplitN(alias, "@", 2) 39 | if len(localDom) > 2 { 40 | return errors.New("alias should be a valid email address or a domain. " + alias + " given") 41 | } 42 | // TODO check domain if domain is valid 43 | if len(localDom) == 1 { 44 | isDomAlias = true 45 | } 46 | 47 | // if domainAlias minilist is forbiden 48 | if isDomAlias && isMiniList { 49 | return errors.New("you can't use --minilist option on dmain alias") 50 | } 51 | 52 | // exists ? 53 | exists, err := AliasExists(alias) 54 | if err != nil { 55 | return err 56 | } 57 | if exists { 58 | return errors.New(alias + " already exists") 59 | } 60 | 61 | // sanity checks if alias is an address 62 | // alias must not be a valid user 63 | if !isDomAlias { 64 | exists, err = UserExists(alias) 65 | if err != nil { 66 | return err 67 | } 68 | if exists { 69 | return errors.New(alias + " is an existing user") 70 | } 71 | 72 | // domain part must be a local domain 73 | rcpthost, err := RcpthostGet(localDom[1]) 74 | if err != nil { 75 | if err == gorm.ErrRecordNotFound { 76 | return errors.New("domain " + localDom[1] + " is not handled by tmail") 77 | } 78 | return err 79 | } 80 | if !rcpthost.IsLocal { 81 | return errors.New("domain part of alias must be a local domain handled by tmail") 82 | } 83 | } else { 84 | // alias is a domain and must be in rcpthost 85 | rcptpHost, err := RcpthostGet(alias) 86 | if err != nil { 87 | if err == gorm.ErrRecordNotFound { 88 | if err = RcpthostAdd(alias, true, true); err != nil { 89 | return errors.New("unable to add " + alias + " as rcpthost") 90 | } 91 | } else { 92 | return err 93 | } 94 | } else { 95 | // domain should be an alias 96 | if !rcptpHost.IsAlias { 97 | return errors.New("domain " + alias + " is and existing domain (and not an alias)") 98 | } 99 | } 100 | } 101 | 102 | // if pipe 103 | if pipe != "" { 104 | pipe = strings.TrimSpace(pipe) 105 | // check the cmd 106 | // first part is the command 107 | cmd := strings.SplitN(pipe, " ", 1) 108 | // file existe and is executable ? 109 | _, err := exec.LookPath(cmd[0]) 110 | if err != nil { 111 | return err 112 | } 113 | 114 | } 115 | if deliverTo != "" { // delivery 116 | dt := []string{} 117 | t := strings.Split(strings.TrimSpace(deliverTo), " ") 118 | for i, d := range t { 119 | rcpt := strings.TrimSpace(d) 120 | if rcpt == "" { 121 | continue 122 | } 123 | if rcpt == alias { 124 | return errors.New("are you drunk ?") 125 | } 126 | if !isDomAlias { 127 | localDomRcpt := strings.Split(rcpt, "@") 128 | if len(localDomRcpt) != 2 { 129 | return errors.New("deliverTo addresses should be valid email addresses. " + rcpt + " given") 130 | } 131 | 132 | // alias domain && rcpt domain should be the same 133 | if localDom[1] != localDomRcpt[1] { 134 | return errors.New("an email alias must have the same domain part than the final recipient") 135 | } 136 | 137 | user, err := UserGetByLogin(rcpt) 138 | if err != nil { 139 | if err == gorm.ErrRecordNotFound { 140 | return errors.New("user " + rcpt + " doesn't exists") 141 | } 142 | return err 143 | } 144 | if !user.HaveMailbox { 145 | return errors.New("user " + rcpt + " doesn't have mailbox account") 146 | } 147 | } else { 148 | // is domain alias 149 | if i > 0 { 150 | return errors.New("a domain can be an alias on only one other domain") 151 | } 152 | // rcpt should be a domain 153 | if strings.Count(rcpt, "@") != 0 { 154 | return errors.New("you must give a domain... for a domain alias. " + rcpt + " given") 155 | } 156 | // domain should be a local domain 157 | domain, err := RcpthostGet(rcpt) 158 | if err != nil { 159 | if err == gorm.ErrRecordNotFound { 160 | return errors.New("domain " + rcpt + " is not a local domain") 161 | } 162 | return err 163 | } else if !domain.IsLocal { 164 | return errors.New("domain " + rcpt + " is not a local domain") 165 | } 166 | } 167 | dt = append(dt, rcpt) 168 | } 169 | if len(dt) != 0 { 170 | deliverTo = strings.Join(dt, ";") 171 | } 172 | } 173 | 174 | return DB.Save(&Alias{ 175 | Alias: alias, 176 | DeliverTo: deliverTo, 177 | Pipe: pipe, 178 | IsDomAlias: isDomAlias, 179 | IsMiniList: isMiniList, 180 | }).Error 181 | } 182 | 183 | // AliasDel is used to delete an alias 184 | func AliasDel(alias string) error { 185 | a, err := AliasGet(alias) 186 | if err != nil { 187 | if err == gorm.ErrRecordNotFound { 188 | return errors.New("Alias " + alias + " doesn't exists") 189 | } 190 | return errors.New("unable to get alias " + alias + ". " + err.Error()) 191 | } 192 | tx := DB.Begin() 193 | if a.IsDomAlias { 194 | if err = tx.Where("hostname=?", a.Alias).Delete(&RcptHost{}).Error; err != nil { 195 | tx.Rollback() 196 | return err 197 | } 198 | } 199 | if err = tx.Where("alias = ?", alias).Delete(&Alias{}).Error; err != nil { 200 | tx.Rollback() 201 | return err 202 | } 203 | return tx.Commit().Error 204 | } 205 | 206 | // AliasList return all alias 207 | func AliasList() (aliases []Alias, err error) { 208 | aliases = []Alias{} 209 | err = DB.Find(&aliases).Error 210 | return aliases, err 211 | } 212 | 213 | // AliasExists checks if an alias exists 214 | func AliasExists(alias string) (bool, error) { 215 | err := DB.Where("alias=?", strings.ToLower(alias)).Find(&Alias{}).Error 216 | if err == nil { 217 | return true, nil 218 | } 219 | if err != gorm.ErrRecordNotFound { 220 | return false, err 221 | } 222 | return false, nil 223 | } 224 | -------------------------------------------------------------------------------- /core/clamav.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "bufio" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "net" 9 | "strings" 10 | ) 11 | 12 | // inspirated from https://github.com/dutchcoders/go-clamd 13 | type clamav struct { 14 | dsn string 15 | conn net.Conn 16 | } 17 | 18 | // NewClamav returns a new clamac wrapper 19 | func NewClamav() *clamav { 20 | return &clamav{dsn: Cfg.GetSmtpdClamavDsns()} 21 | } 22 | 23 | // connect make the connexion 24 | func (c *clamav) connect() (err error) { 25 | c.conn, err = net.Dial("unix", c.dsn) 26 | return err 27 | } 28 | 29 | // Cmd send a command to clamav and return the reply 30 | func (c *clamav) Cmd(command string) (reply string, err error) { 31 | reply = "" 32 | if err = c.connect(); err != nil { 33 | return 34 | } 35 | defer c.conn.Close() 36 | _, err = c.conn.Write([]byte(fmt.Sprintf("n%s\n", command))) 37 | if err != nil { 38 | return 39 | } 40 | reader := bufio.NewReader(c.conn) 41 | for { 42 | line, err := reader.ReadString('\n') 43 | if err == io.EOF { 44 | break 45 | } 46 | if err != nil { 47 | return reply, err 48 | } 49 | 50 | reply = reply + strings.TrimRight(line, " \t\r\n") 51 | } 52 | return 53 | } 54 | 55 | // Ping send a ping command and checks if reply is PONG 56 | func (c *clamav) Ping() error { 57 | r, err := c.Cmd("PING") 58 | if err != nil { 59 | return err 60 | } 61 | // Should be PONG 62 | if r != "PONG" { 63 | return errors.New("PONG expected, got " + r) 64 | } 65 | return nil 66 | } 67 | 68 | // ScanStream scan a stream of byte 69 | // TODO: timeout 70 | func (c *clamav) ScanStream(r io.Reader) (bool, string, error) { 71 | const CHUNK_SIZE = 1024 72 | var err error 73 | 74 | if err = c.connect(); err != nil { 75 | return false, "", err 76 | } 77 | defer c.conn.Close() 78 | _, err = c.conn.Write([]byte("nINSTREAM\n")) 79 | if err != nil { 80 | return false, "", err 81 | } 82 | 83 | for { 84 | inbuf := make([]byte, CHUNK_SIZE) // Todo clear buffer instead of init a new one 85 | _, err := r.Read(inbuf) 86 | if err == io.EOF { 87 | break 88 | } 89 | if err != nil { 90 | return false, "", err 91 | } 92 | //if nb > 0 { 93 | //log.Printf("Error %v, %v, %v", buf[0:nr], nr, err) 94 | //conn.sendChunk(buf[0:nr]) 95 | var outbuf [4]byte 96 | lenData := len(inbuf) 97 | outbuf[0] = byte(lenData >> 24) 98 | outbuf[1] = byte(lenData >> 16) 99 | outbuf[2] = byte(lenData >> 8) 100 | outbuf[3] = byte(lenData >> 0) 101 | 102 | a := outbuf 103 | 104 | b := make([]byte, len(a)) 105 | for i := range a { 106 | b[i] = a[i] 107 | } 108 | if _, err = c.conn.Write(b); err != nil { 109 | return false, "", err 110 | } 111 | if _, err = c.conn.Write(inbuf); err != nil { 112 | return false, "", err 113 | } 114 | //} 115 | } 116 | 117 | // send EOF 118 | _, err = c.conn.Write([]byte{0, 0, 0, 0}) 119 | if err != nil { 120 | return false, "", err 121 | } 122 | 123 | // read response 124 | reply := "" 125 | reader := bufio.NewReader(c.conn) 126 | for { 127 | line, err := reader.ReadString('\n') 128 | if err == io.EOF { 129 | break 130 | } 131 | if err != nil { 132 | return false, "", err 133 | } 134 | 135 | reply = reply + strings.TrimRight(line, " \t\r\n") 136 | } 137 | if strings.HasSuffix(reply, "FOUND") { 138 | virus := strings.Split(reply, " ")[1] 139 | return true, virus, nil 140 | } 141 | return false, "", nil 142 | } 143 | -------------------------------------------------------------------------------- /core/database.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/jinzhu/gorm" 7 | ) 8 | 9 | // IsOkDB checks if database is ok 10 | func IsOkDB(DB *gorm.DB) bool { 11 | // Check if all tables exists 12 | // user 13 | if !DB.HasTable(&User{}) { 14 | return false 15 | } 16 | if !DB.HasTable(&Alias{}) { 17 | return false 18 | } 19 | if !DB.HasTable(&RcptHost{}) { 20 | return false 21 | } 22 | if !DB.HasTable(&Mailbox{}) { 23 | return false 24 | } 25 | if !DB.HasTable(&RelayIpOk{}) { 26 | return false 27 | } 28 | if !DB.HasTable(&QMessage{}) { 29 | return false 30 | } 31 | if !DB.HasTable(&Route{}) { 32 | return false 33 | } 34 | if !DB.HasTable(&DkimConfig{}) { 35 | return false 36 | } 37 | return true 38 | } 39 | 40 | // InitDB create tables if needed and initialize them 41 | // TODO: SKIP in CLI 42 | // TODO: check regularly structure & indexes 43 | func InitDB(DB *gorm.DB) error { 44 | var err error 45 | //users table 46 | if !DB.HasTable(&User{}) { 47 | if err = DB.CreateTable(&User{}).Error; err != nil { 48 | return errors.New("Unable to create table user - " + err.Error()) 49 | } 50 | } 51 | 52 | // Alias 53 | if !DB.HasTable(&Alias{}) { 54 | if err = DB.CreateTable(&Alias{}).Error; err != nil { 55 | return errors.New("Unable to create table Alias - " + err.Error()) 56 | } 57 | } 58 | 59 | //rcpthosts table 60 | if !DB.HasTable(&RcptHost{}) { 61 | if err = DB.CreateTable(&RcptHost{}).Error; err != nil { 62 | return errors.New("Unable to create RcptHost - " + err.Error()) 63 | } 64 | // Index 65 | if err = DB.Model(&RcptHost{}).AddIndex("idx_rcpthots_hostname", "hostname").Error; err != nil { 66 | return errors.New("Unable to add index idx_rcpthots_domain on table RcptHost - " + err.Error()) 67 | } 68 | } 69 | 70 | // mailbox 71 | if !DB.HasTable(&Mailbox{}) { 72 | if err = DB.CreateTable(&Mailbox{}).Error; err != nil { 73 | return errors.New("Unable to create Mailbox - " + err.Error()) 74 | } 75 | // Index 76 | } 77 | 78 | //relay_ip_oks table 79 | if !DB.HasTable(&RelayIpOk{}) { 80 | if err = DB.CreateTable(&RelayIpOk{}).Error; err != nil { 81 | return errors.New("Unable to create relay_ok_ips - " + err.Error()) 82 | } 83 | // Index 84 | if err = DB.Model(&RelayIpOk{}).AddIndex("idx_relay_ok_ips_ip", "ip").Error; err != nil { 85 | return errors.New("Unable to add index idx_rcpthots_domain on table relay_ok_ips - " + err.Error()) 86 | } 87 | } 88 | 89 | //queued_messages table 90 | if !DB.HasTable(&QMessage{}) { 91 | if err = DB.CreateTable(&QMessage{}).Error; err != nil { 92 | return errors.New("Unable to create table queued_messages - " + err.Error()) 93 | } 94 | } 95 | // deliverd.route 96 | if !DB.HasTable(&Route{}) { 97 | if err = DB.CreateTable(&Route{}).Error; err != nil { 98 | return errors.New("Unable to create table route - " + err.Error()) 99 | } 100 | // Index 101 | if err = DB.Model(&Route{}).AddIndex("idx_route_host", "host").Error; err != nil { 102 | return errors.New("Unable to add index idx_route_host on table route - " + err.Error()) 103 | } 104 | } 105 | 106 | if !DB.HasTable(&DkimConfig{}) { 107 | if err = DB.CreateTable(&DkimConfig{}).Error; err != nil { 108 | return errors.New("Unable to create table dkim_config - " + err.Error()) 109 | } 110 | // Index 111 | if err = DB.Model(&DkimConfig{}).AddIndex("idx_domain", "domain").Error; err != nil { 112 | return errors.New("Unable to add index idx_domain on table dkim_config - " + err.Error()) 113 | } 114 | } 115 | 116 | return nil 117 | } 118 | 119 | // AutoMigrateDB will keep tables reflecting structs 120 | func AutoMigrateDB(DB *gorm.DB) error { 121 | // if tables exists check if they reflects struts 122 | if err := DB.AutoMigrate(&User{}, &Alias{}, &RcptHost{}, &RelayIpOk{}, &QMessage{}, &Route{}, &DkimConfig{}).Error; err != nil { 123 | return errors.New("Unable autoMigrateDB - " + err.Error()) 124 | } 125 | return nil 126 | } 127 | -------------------------------------------------------------------------------- /core/deliverd.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | // TODO consumer.SetLogger 4 | 5 | import ( 6 | "log" 7 | "os" 8 | "os/signal" 9 | "syscall" 10 | 11 | "github.com/nsqio/go-nsq" 12 | ) 13 | 14 | /*type deliverd struct { 15 | } 16 | 17 | func New() *deliverd { 18 | return &deliverd{} 19 | }*/ 20 | 21 | // LaunchDeliverd launch deliverd 22 | func LaunchDeliverd() { 23 | sigChan := make(chan os.Signal, 1) 24 | signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) 25 | cfg := nsq.NewConfig() 26 | 27 | cfg.UserAgent = "tmail/deliverd" 28 | cfg.MaxInFlight = ((Cfg.GetDeliverdConcurrencyLocal() + Cfg.GetDeliverdConcurrencyRemote()) * 200) / 100 29 | // MaxAttempts: number of attemps for a message before sending a 30 | // 1 [queueRemote/deliverd] msg 07814777d6312000 attempted 6 times, giving up 31 | cfg.MaxAttempts = 0 32 | 33 | // create consummer 34 | // TODO creation de plusieurs consumer: local, remote, ... 35 | consumer, err := nsq.NewConsumer("todeliver", "deliverd", cfg) 36 | if err != nil { 37 | log.Fatalln(err) 38 | } 39 | if Cfg.GetDebugEnabled() { 40 | consumer.SetLogger(NewNSQLogger(), nsq.LogLevelDebug) 41 | } else { 42 | consumer.SetLogger(NewNSQLogger(), nsq.LogLevelError) 43 | } 44 | // Bind handler 45 | consumer.AddHandler(&deliveryHandler{}) 46 | 47 | // connect 48 | if Cfg.GetClusterModeEnabled() { 49 | err = consumer.ConnectToNSQLookupds(Cfg.GetNSQLookupdHttpAddresses()) 50 | } else { 51 | err = consumer.ConnectToNSQDs([]string{"127.0.0.1:4150"}) 52 | } 53 | if err != nil { 54 | log.Fatalln(err) 55 | } 56 | 57 | Logger.Info("deliverd launched") 58 | 59 | for { 60 | select { 61 | case <-consumer.StopChan: 62 | return 63 | case <-sigChan: 64 | consumer.Stop() 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /core/deliverd_auth.go: -------------------------------------------------------------------------------- 1 | // Modified version of the standard auth.go librarie 2 | 3 | // Copyright 2010 The Go Authors. All rights reserved. 4 | // Use of this source code is governed by a BSD-style 5 | // license that can be found in the LICENSE file. 6 | 7 | package core 8 | 9 | import ( 10 | "crypto/hmac" 11 | "crypto/md5" 12 | "errors" 13 | "fmt" 14 | //"net" 15 | ) 16 | 17 | // Auth is implemented by an SMTP authentication mechanism. 18 | type DeliverdAuth interface { 19 | // Start begins an authentication with a server. 20 | // It returns the name of the authentication protocol 21 | // and optionally data to include in the initial AUTH message 22 | // sent to the server. It can return proto == "" to indicate 23 | // that the authentication should be skipped. 24 | // If it returns a non-nil error, the SMTP client aborts 25 | // the authentication attempt and closes the connection. 26 | Start(server *ServerInfo) (proto string, toServer []byte, err error) 27 | 28 | // Next continues the authentication. The server has just sent 29 | // the fromServer data. If more is true, the server expects a 30 | // response, which Next should return as toServer; otherwise 31 | // Next should return toServer == nil. 32 | // If Next returns a non-nil error, the SMTP client aborts 33 | // the authentication attempt and closes the connection. 34 | Next(fromServer []byte, more bool) (toServer []byte, err error) 35 | } 36 | 37 | // ServerInfo records information about an SMTP server. 38 | type ServerInfo struct { 39 | //Address net.TCPAddr // SMTP adrress 40 | Name string // SMTP server name 41 | TLS bool // using TLS, with valid certificate for Name 42 | Auth []string // advertised authentication mechanisms 43 | } 44 | 45 | type plainAuth struct { 46 | identity, username, password string 47 | host string 48 | } 49 | 50 | // PlainAuth returns an Auth that implements the PLAIN authentication 51 | // mechanism as defined in RFC 4616. 52 | // The returned Auth uses the given username and password to authenticate 53 | // on TLS connections to host and act as identity. Usually identity will be 54 | // left blank to act as username. 55 | func PlainAuth(identity, username, password, host string) DeliverdAuth { 56 | return &plainAuth{identity, username, password, host} 57 | } 58 | 59 | func (a *plainAuth) Start(server *ServerInfo) (string, []byte, error) { 60 | if !server.TLS { 61 | advertised := false 62 | for _, mechanism := range server.Auth { 63 | if mechanism == "PLAIN" { 64 | advertised = true 65 | break 66 | } 67 | } 68 | if !advertised { 69 | return "", nil, errors.New("unencrypted connection") 70 | } 71 | } 72 | if server.Name != a.host { 73 | return "", nil, errors.New("wrong host name") 74 | } 75 | resp := []byte(a.identity + "\x00" + a.username + "\x00" + a.password) 76 | return "PLAIN", resp, nil 77 | } 78 | 79 | func (a *plainAuth) Next(fromServer []byte, more bool) ([]byte, error) { 80 | if more { 81 | // We've already sent everything. 82 | return nil, errors.New("unexpected server challenge") 83 | } 84 | return nil, nil 85 | } 86 | 87 | type cramMD5Auth struct { 88 | username, secret string 89 | } 90 | 91 | // CRAMMD5Auth returns an Auth that implements the CRAM-MD5 authentication 92 | // mechanism as defined in RFC 2195. 93 | // The returned Auth uses the given username and secret to authenticate 94 | // to the server using the challenge-response mechanism. 95 | func CRAMMD5Auth(username, secret string) DeliverdAuth { 96 | return &cramMD5Auth{username, secret} 97 | } 98 | 99 | func (a *cramMD5Auth) Start(server *ServerInfo) (string, []byte, error) { 100 | return "CRAM-MD5", nil, nil 101 | } 102 | 103 | func (a *cramMD5Auth) Next(fromServer []byte, more bool) ([]byte, error) { 104 | if more { 105 | d := hmac.New(md5.New, []byte(a.secret)) 106 | d.Write(fromServer) 107 | s := make([]byte, 0, d.Size()) 108 | return []byte(fmt.Sprintf("%s %x", a.username, d.Sum(s))), nil 109 | } 110 | return nil, nil 111 | } 112 | -------------------------------------------------------------------------------- /core/deliverd_handler.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/nsqio/go-nsq" 7 | ) 8 | 9 | type deliveryHandler struct { 10 | } 11 | 12 | // HandleMessage implement interface 13 | func (h *deliveryHandler) HandleMessage(m *nsq.Message) error { 14 | var err error 15 | d := new(Delivery) 16 | d.ID, err = NewUUID() 17 | if err != nil { 18 | // TODO gerer mieux cette erreur 19 | Logger.Error("deliverd: unable to create uuid for new delivery") 20 | m.RequeueWithoutBackoff(10 * time.Minute) 21 | return err 22 | } 23 | d.StartAt = time.Now() 24 | d.NSQMsg = m 25 | d.QMsg = new(QMessage) 26 | // disable autoresponse otherwise no goroutines 27 | m.DisableAutoResponse() 28 | go d.processMsg() 29 | return nil 30 | } 31 | -------------------------------------------------------------------------------- /core/deliverd_local.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "os/exec" 8 | "strconv" 9 | "strings" 10 | "syscall" 11 | "time" 12 | 13 | "github.com/jinzhu/gorm" 14 | 15 | "github.com/toorop/tmail/message" 16 | ) 17 | 18 | // deliverLocal handle local delivery 19 | func deliverLocal(d *Delivery) { 20 | var dataBuf *bytes.Buffer 21 | mailboxAvailable := false 22 | localRcpt := []string{} 23 | 24 | Logger.Info(fmt.Sprintf("delivery-local %s: starting new delivery from %s to %s - Message-Id: %s - Queue-Id: %s", d.ID, d.QMsg.MailFrom, d.QMsg.RcptTo, d.QMsg.MessageId, d.QMsg.Uuid)) 25 | deliverTo := d.QMsg.RcptTo 26 | 27 | // if it's not a local user checks for alias 28 | user, err := UserGetByLogin(d.QMsg.RcptTo) 29 | if err != nil && err != gorm.ErrRecordNotFound { 30 | d.dieTemp(fmt.Sprintf("delivery-local %s: unable to check if %s is a real user. %s", d.ID, d.QMsg.RcptTo, err), true) 31 | return 32 | } 33 | // user exists 34 | if err == nil { 35 | mailboxAvailable = user.HaveMailbox 36 | } 37 | 38 | // If there non mailbox for this RCPT 39 | if !mailboxAvailable { 40 | localDom := strings.Split(d.QMsg.RcptTo, "@") 41 | // first checks if it's an email alias ? 42 | alias, err := AliasGet(d.QMsg.RcptTo) 43 | if err != nil && err != gorm.ErrRecordNotFound { 44 | d.dieTemp(fmt.Sprintf("delivery-local %s: unable to check if %s is an alias. %s", d.ID, d.QMsg.RcptTo, err), true) 45 | return 46 | } 47 | 48 | // domain alias ? 49 | if err != nil && err == gorm.ErrRecordNotFound { 50 | if len(localDom) == 2 { 51 | alias, err = AliasGet(localDom[1]) 52 | if err != nil && err != gorm.ErrRecordNotFound { 53 | d.dieTemp(fmt.Sprintf("delivery-local %s: unable to check if %s is an alias. %s", d.ID, localDom[1], err), true) 54 | return 55 | } 56 | } 57 | } 58 | 59 | // err == nil -> err != gorm.ErrRecordNotFound -> alias exists (email or domain) 60 | if err == nil { 61 | // Pipe 62 | if alias.Pipe != "" { 63 | // expected exit status for pipe cmd 64 | // 0: OK 65 | // 4: temp fail 66 | // 5: perm fail 67 | dataBuf := bytes.NewBuffer(*d.RawData) 68 | 69 | cmd := exec.Command(strings.Join(strings.Split(alias.Pipe, " "), ",")) 70 | stdin, err := cmd.StdinPipe() 71 | if err != nil { 72 | d.dieTemp(fmt.Sprintf("delivery-local %s: unable to create stddin pipe to %s. %s", d.ID, alias.Pipe, err.Error()), true) 73 | return 74 | } 75 | if err != nil { 76 | d.dieTemp(fmt.Sprintf("delivery-local %s: unable to create stdout pipe from %s. %s", d.ID, alias.Pipe, err.Error()), true) 77 | } 78 | if err := cmd.Start(); err != nil { 79 | d.dieTemp(fmt.Sprintf("delivery-local %s: unable to exec pipe %s. %s", d.ID, alias.Pipe, err.Error()), true) 80 | return 81 | } 82 | _, err = io.Copy(stdin, dataBuf) 83 | if err != nil { 84 | d.dieTemp(fmt.Sprintf("delivery-local %s: unable to pipe mail to cmd %s. %s", d.ID, alias.Pipe, err.Error()), true) 85 | return 86 | } 87 | stdin.Close() 88 | 89 | if err := cmd.Wait(); err != nil { 90 | if msg, ok := err.(*exec.ExitError); ok { 91 | exitStatus := msg.Sys().(syscall.WaitStatus).ExitStatus() 92 | switch exitStatus { 93 | case 5: 94 | d.diePerm(fmt.Sprintf("delivery-local %s: cmd %s failed with exit code 5 (perm failure)", d.ID, alias.Pipe), true) 95 | return 96 | case 4: 97 | d.dieTemp(fmt.Sprintf("delivery-local %s: cmd %s failed with exit code 4 (temp failure)", d.ID, alias.Pipe), true) 98 | return 99 | default: 100 | d.diePerm(fmt.Sprintf("delivery-local %s: cmd %s return unexpected exit code %d", d.ID, alias.Pipe, exitStatus), true) 101 | return 102 | } 103 | } else { 104 | d.diePerm(fmt.Sprintf("delivery-local %s: cmd %s oops something went wrong %s", d.ID, alias.Pipe, err), true) 105 | return 106 | } 107 | } 108 | Logger.Info(fmt.Sprintf("delivery-local %s: cmd %s succeeded", d.ID, alias.Pipe)) 109 | } 110 | 111 | // deliverTo 112 | if alias.DeliverTo != "" { 113 | localRcpt = strings.Split(alias.DeliverTo, ";") 114 | if alias.IsDomAlias { 115 | localRcpt = []string{localDom[0] + "@" + localRcpt[0]} 116 | } 117 | enveloppe := message.Envelope{ 118 | MailFrom: d.QMsg.MailFrom, 119 | RcptTo: localRcpt, 120 | } 121 | // rem: no minilist for domainAlias 122 | if enveloppe.MailFrom != "" && alias.IsMiniList && !alias.IsDomAlias { 123 | enveloppe.MailFrom = alias.Alias 124 | } 125 | uuid, err := QueueAddMessage(d.RawData, enveloppe, "") 126 | if err != nil { 127 | d.dieTemp(fmt.Sprintf("delivery-local %s: unable to requeue aliased msg: %s", d.ID, err), true) 128 | return 129 | } 130 | Logger.Info(fmt.Sprintf("delivery-local %s: rcpt is an alias, mail is requeue with ID %s for final rcpt: %s", d.ID, uuid, strings.Join(localRcpt, " "))) 131 | } 132 | d.dieOk() 133 | return 134 | } 135 | // search for a catchall 136 | user, err = UserGetCatchallForDomain(localDom[1]) 137 | if err != nil { 138 | d.dieTemp(fmt.Sprintf("delivery-local %s: unable to search a catchall for rcpt%s. %s", d.ID, localDom[1], err), true) 139 | return 140 | } 141 | if user != nil { 142 | deliverTo = user.Login 143 | } 144 | } 145 | 146 | // TODO Remove return path 147 | //msg.DelHeader("return-path") 148 | 149 | // Received 150 | *d.RawData = append([]byte("Received: tmail deliverd local "+d.ID+"; "+time.Now().Format(Time822)+"\r\n"), *d.RawData...) 151 | 152 | // Delivered-To 153 | *d.RawData = append([]byte("Delivered-To: "+deliverTo+"\r\n"), *d.RawData...) 154 | 155 | // Return path 156 | *d.RawData = append([]byte("Return-Path: "+d.QMsg.MailFrom+"\r\n"), *d.RawData...) 157 | 158 | dataBuf = bytes.NewBuffer(*d.RawData) 159 | 160 | cmd := exec.Command(Cfg.GetDovecotLda(), "-d", deliverTo) 161 | stdin, err := cmd.StdinPipe() 162 | if err != nil { 163 | d.dieTemp(fmt.Sprintf("delivery-local %s: unable to create pipe to dovecot-lda stdin: %s", d.ID, err), true) 164 | return 165 | } 166 | 167 | if err := cmd.Start(); err != nil { 168 | d.dieTemp(fmt.Sprintf("delivery-local %s: unable to run dovecot-lda: %s", d.ID, err), true) 169 | return 170 | } 171 | 172 | _, err = io.Copy(stdin, dataBuf) 173 | if err != nil { 174 | d.dieTemp(fmt.Sprintf("delivery-local %s: unable to pipe mail to dovecot-lda: %s", d.ID, err), true) 175 | return 176 | } 177 | stdin.Close() 178 | 179 | if err := cmd.Wait(); err != nil { 180 | t := strings.Split(err.Error(), " ") 181 | if len(t) != 3 { 182 | d.dieTemp(fmt.Sprintf("delivery-local %s: unexpected response from dovecot-lda: %s", d.ID, err), true) 183 | return 184 | } 185 | errCode, err := strconv.ParseUint(t[2], 10, 64) 186 | if err != nil { 187 | d.dieTemp(fmt.Sprintf("delivery-local %s: unable to parse response from dovecot-lda: %s", d.ID, err), true) 188 | return 189 | } 190 | switch errCode { 191 | case 64: 192 | d.dieTemp(fmt.Sprintf("delivery-local %s: dovecot-lda return: 64 - Invalid parameter given", d.ID), true) 193 | case 67: 194 | d.diePerm(fmt.Sprintf("delivery-local %s: the destination user %s was not found", d.ID, deliverTo), true) 195 | case 77: 196 | d.diePerm(fmt.Sprintf("delivery-local %s: the destination user %s is over quota", d.ID, deliverTo), true) 197 | case 75: 198 | d.dieTemp(fmt.Sprintf("delivery-local %s: dovecot temporary failure. Checks dovecot log for more info", d.ID), true) 199 | default: 200 | d.dieTemp(fmt.Sprintf("delivery-local %s: unexpected response code recieved from dovecot-lda: %d", d.ID, errCode), true) 201 | } 202 | return 203 | } 204 | Logger.Info(fmt.Sprintf("delivery-local %s: delivered to %s", d.ID, deliverTo)) 205 | 206 | d.dieOk() 207 | } 208 | -------------------------------------------------------------------------------- /core/deliverd_remote.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "bytes" 5 | "crypto/tls" 6 | "fmt" 7 | "io" 8 | "strings" 9 | "time" 10 | 11 | "github.com/toorop/go-dkim" 12 | ) 13 | 14 | func deliverRemote(d *Delivery) { 15 | var err error 16 | ChDeliverdConcurrencyRemoteCount <- 1 17 | defer func() { ChDeliverdConcurrencyRemoteCount <- -1 }() 18 | 19 | // > concurrency remote ? 20 | if DeliverdConcurrencyRemoteCount >= Cfg.GetDeliverdConcurrencyRemote() { 21 | d.requeue() 22 | return 23 | } 24 | 25 | time.Sleep(100 * time.Nanosecond) 26 | Logger.Info(fmt.Sprintf("delivery-remote %s: starting new remote delivery %d/%d from %s to %s - Message-Id: %s - Queue-Id: %s", d.ID, DeliverdConcurrencyRemoteCount, Cfg.GetDeliverdConcurrencyRemote(), d.QMsg.MailFrom, d.QMsg.RcptTo, d.QMsg.MessageId, d.QMsg.Uuid)) 27 | 28 | // gatling tests 29 | //Logger.Info(fmt.Sprintf("deliverd-remote %s: done for gatling test", d.ID)) 30 | //d.dieOk() 31 | //return 32 | 33 | // Get routes 34 | d.RemoteRoutes = []Route{} 35 | 36 | // plugins 37 | // if plugin return false return 38 | if !execDeliverdPlugins("remoteinit", d) { 39 | return 40 | } 41 | 42 | // Default routes 43 | if len(d.RemoteRoutes) == 0 { 44 | d.RemoteRoutes, err = getRoutes(d.QMsg.MailFrom, d.QMsg.Host, d.QMsg.AuthUser) 45 | if err != nil { 46 | d.dieTemp("unable to get route to host "+d.QMsg.Host+". "+err.Error(), true) 47 | return 48 | } 49 | } 50 | 51 | // No routes ?? WTF ! 52 | if len(d.RemoteRoutes) == 0 { 53 | d.dieTemp("no route to host "+d.QMsg.Host, true) 54 | return 55 | } 56 | 57 | // Get client 58 | client, err := newSMTPClient(d, d.RemoteRoutes, Cfg.GetDeliverdRemoteTimeout()) 59 | if err != nil { 60 | Logger.Error(fmt.Sprintf("deliverd-remote %s - %s", d.ID, err.Error())) 61 | d.dieTemp("unable to get client", false) 62 | return 63 | } 64 | defer client.close() 65 | 66 | d.RemoteAddr = client.RemoteAddr() 67 | d.LocalAddr = client.LocalAddr() 68 | 69 | // EHLO 70 | code, msg, err := client.Hello() 71 | d.RemoteSMTPresponseCode = code 72 | if err != nil { 73 | switch { 74 | case code > 399 && code < 500: 75 | d.dieTemp(fmt.Sprintf("deliverd-remote %s - %s - HELO failed %v - remote server reply %d %s ", d.ID, client.RemoteAddr(), err.Error(), code, msg), true) 76 | return 77 | case code > 499: 78 | d.diePerm(fmt.Sprintf("deliverd-remote %s - %s - HELO failed %v - remote server reply %d %s ", d.ID, client.RemoteAddr(), err.Error(), code, msg), true) 79 | return 80 | default: 81 | Logger.Info(fmt.Sprintf("deliverd-remote %s - %s - HELO unexpected code, remote server reply %d %s ", d.ID, client.RemoteAddr(), code, msg)) 82 | } 83 | } 84 | 85 | // STARTTLS ? 86 | // 2013-06-22 14:19:30.670252500 delivery 196893: deferral: Sorry_but_i_don't_understand_SMTP_response_:_local_error:_unexpected_message_/ 87 | // 2013-06-18 10:08:29.273083500 delivery 856840: deferral: Sorry_but_i_don't_understand_SMTP_response_:_failed_to_parse_certificate_from_server:_negative_serial_number_/ 88 | // https://code.google.com/p/go/issues/detail?id=3930data 89 | if ok, _ := client.Extension("STARTTLS"); ok { 90 | var config tls.Config 91 | config.InsecureSkipVerify = Cfg.GetDeliverdRemoteTLSSkipVerify() 92 | //config.ServerName = Cfg.GetMe() 93 | code, msg, err = client.StartTLS(&config) 94 | d.RemoteSMTPresponseCode = code 95 | // Warning debug 96 | //err := fmt.Errorf("fake tls error") 97 | if err != nil { 98 | Logger.Info(fmt.Sprintf("deliverd-remote %s - %s - TLS negociation failed %d - %s - %v .", d.ID, client.conn.RemoteAddr().String(), code, msg, err)) 99 | if Cfg.GetDeliverdRemoteTLSFallback() { 100 | // fall back to noTLS 101 | Logger.Info(fmt.Sprintf("deliverd-remote %s - %s - fallback to no TLS.", d.ID, client.conn.RemoteAddr().String())) 102 | client.close() 103 | client, err = newSMTPClient(d, d.RemoteRoutes, Cfg.GetDeliverdRemoteTimeout()) 104 | if err != nil { 105 | Logger.Error(fmt.Sprintf("deliverd-remote %s - fallback to no TLS failed - %s", d.ID, err.Error())) 106 | d.dieTemp("unable to get client", false) 107 | return 108 | } 109 | defer client.close() 110 | code, msg, err = client.Hello() 111 | if err != nil { 112 | switch { 113 | case code > 399 && code < 500: 114 | d.dieTemp(fmt.Sprintf("deliverd-remote %s - %s - HELO failed %v - remote server reply %d %s ", d.ID, client.RemoteAddr(), err.Error(), code, msg), true) 115 | return 116 | case code > 499: 117 | d.diePerm(fmt.Sprintf("deliverd-remote %s - %s - HELO failed %v - remote server reply %d %s ", d.ID, client.RemoteAddr(), err.Error(), code, msg), true) 118 | return 119 | default: 120 | d.dieTemp(fmt.Sprintf("deliverd-remote %s - %s - HELO unexpected code, remote server reply %d %s ", d.ID, client.RemoteAddr(), code, msg), true) 121 | return 122 | //Logger.Info(fmt.Sprintf("deliverd-remote %s - %s - HELO unexpected code, remote server reply %d %s ", d.ID, client.RemoteAddr(), code, msg)) 123 | } 124 | } 125 | } else { 126 | d.dieTemp(fmt.Sprintf("deliverd-remote %s - %s - TLS negociation failed %d - %s - %v .", d.ID, client.conn.RemoteAddr().String(), code, msg, err), true) 127 | return 128 | } 129 | } else { 130 | Logger.Info(fmt.Sprintf("deliverd-remote %s - %s - TLS negociation succeed - %s %s", d.ID, client.RemoteAddr(), client.TLSGetVersion(), client.TLSGetCipherSuite())) 131 | } 132 | } 133 | 134 | // SMTP AUTH 135 | if client.route.SmtpAuthLogin.Valid && client.route.SmtpAuthPasswd.Valid && len(client.route.SmtpAuthLogin.String) != 0 && len(client.route.SmtpAuthLogin.String) != 0 { 136 | var auth DeliverdAuth 137 | _, auths := client.Extension("AUTH") 138 | if strings.Contains(auths, "CRAM-MD5") { 139 | auth = CRAMMD5Auth(client.route.SmtpAuthLogin.String, client.route.SmtpAuthPasswd.String) 140 | } else { // PLAIN 141 | auth = PlainAuth("", client.route.SmtpAuthLogin.String, client.route.SmtpAuthPasswd.String, client.route.RemoteHost) 142 | } 143 | if auth != nil { 144 | _, msg, err := client.Auth(auth) 145 | if err != nil { 146 | message := fmt.Sprintf("deliverd-remote %s - %s - AUTH failed - %s - %s", d.ID, client.RemoteAddr(), msg, err) 147 | Logger.Error(message) 148 | d.diePerm(message, false) 149 | return 150 | } 151 | } 152 | } 153 | 154 | // MAIL FROM 155 | code, msg, err = client.Mail(d.QMsg.MailFrom) 156 | d.RemoteSMTPresponseCode = code 157 | if err != nil { 158 | message := fmt.Sprintf("deliverd-remote %s - %s - MAIL FROM %s failed %s - %s", d.ID, client.RemoteAddr(), d.QMsg.MailFrom, msg, err) 159 | Logger.Error(message) 160 | d.handleSMTPError(code, message) 161 | return 162 | } 163 | 164 | // RCPT TO 165 | code, msg, err = client.Rcpt(d.QMsg.RcptTo) 166 | d.RemoteSMTPresponseCode = code 167 | if err != nil { 168 | message := fmt.Sprintf("deliverd-remote %s - %s - RCPT TO %s failed - %s - %s", d.ID, client.RemoteAddr(), d.QMsg.RcptTo, msg, err) 169 | Logger.Error(message) 170 | d.handleSMTPError(code, message) 171 | return 172 | } 173 | 174 | // DATA 175 | dataPipe, code, msg, err := client.Data() 176 | d.RemoteSMTPresponseCode = code 177 | if err != nil { 178 | message := fmt.Sprintf("deliverd-remote %s - %s - DATA command failed - %s - %s", d.ID, client.RemoteAddr(), msg, err) 179 | Logger.Error(message) 180 | d.handleSMTPError(code, message) 181 | return 182 | } 183 | 184 | // add Received headers 185 | *d.RawData = append([]byte("Received: tmail deliverd remote "+d.ID+"; "+time.Now().Format(Time822)+"\r\n"), *d.RawData...) 186 | 187 | // DKIM ? 188 | if Cfg.GetDeliverdDkimSign() { 189 | userDomain := strings.SplitN(d.QMsg.MailFrom, "@", 2) 190 | if len(userDomain) == 2 { 191 | dkc, err := DkimGetConfig(userDomain[1]) 192 | if err != nil { 193 | message := "deliverd-remote " + d.ID + " - unable to get DKIM config for domain " + userDomain[1] + " - " + err.Error() 194 | Logger.Error(message) 195 | d.dieTemp(message, false) 196 | return 197 | } 198 | if dkc != nil { 199 | Logger.Debug(fmt.Sprintf("deliverd-remote %s: add dkim sign", d.ID)) 200 | dkimOptions := dkim.NewSigOptions() 201 | dkimOptions.PrivateKey = []byte(dkc.PrivKey) 202 | dkimOptions.AddSignatureTimestamp = true 203 | dkimOptions.Domain = userDomain[1] 204 | dkimOptions.Selector = dkc.Selector 205 | dkimOptions.Headers = []string{"from", "subject", "date", "message-id"} 206 | dkim.Sign(d.RawData, dkimOptions) 207 | Logger.Debug(fmt.Sprintf("deliverd-remote %s: end dkim sign", d.ID)) 208 | } 209 | } 210 | } 211 | 212 | dataBuf := bytes.NewBuffer(*d.RawData) 213 | _, err = io.Copy(dataPipe, dataBuf) 214 | if err != nil { 215 | message := "deliverd-remote " + d.ID + " - " + client.RemoteAddr() + " - unable to copy dataBuf to dataPipe DKIM config for domain " + " - " + err.Error() 216 | Logger.Error(message) 217 | d.dieTemp(message, false) 218 | return 219 | } 220 | 221 | dataPipe.WriteCloser.Close() 222 | code, msg, err = dataPipe.s.text.ReadResponse(-1) 223 | d.RemoteSMTPresponseCode = code 224 | Logger.Info(fmt.Sprintf("deliverd-remote %s - %s - reply to DATA cmd: %d - %s - %v", d.ID, client.RemoteAddr(), code, msg, err)) 225 | if err != nil { 226 | message := fmt.Sprintf("deliverd-remote %s - %s - DATA command failed - %s - %s", d.ID, client.RemoteAddr(), msg, err) 227 | Logger.Error(message) 228 | d.dieTemp(message, false) 229 | return 230 | } 231 | 232 | if code != 250 { 233 | message := fmt.Sprintf("deliverd-remote %s - %s - DATA command failed - %d - %s", d.ID, client.RemoteAddr(), code, msg) 234 | Logger.Error(message) 235 | d.handleSMTPError(code, message) 236 | return 237 | } 238 | 239 | // Bye 240 | client.Quit() 241 | d.dieOk() 242 | } 243 | -------------------------------------------------------------------------------- /core/deliverd_route.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | //"errors" 5 | "database/sql" 6 | "errors" 7 | "math/rand" 8 | "net" 9 | "sort" 10 | "strings" 11 | ) 12 | 13 | // Route represents a route in DB 14 | type Route struct { 15 | Id int64 16 | Host string `sql:not null` // destination 17 | LocalIp sql.NullString 18 | RemoteHost string `sql:not null` 19 | RemotePort sql.NullInt64 20 | Priority sql.NullInt64 21 | SmtpAuthLogin sql.NullString 22 | SmtpAuthPasswd sql.NullString 23 | MailFrom sql.NullString 24 | User sql.NullString 25 | } 26 | 27 | // routes represents all the routes allowed to access remote MX 28 | /*type matchingRoutes struct { 29 | localIp []net.IP 30 | remoteAddr []net.TCPAddr 31 | }*/ 32 | 33 | type matchingRoutes struct { 34 | routes []Route 35 | } 36 | 37 | // GetAllRoutes returns all routes (really ?!) 38 | func GetAllRoutes() (routes []Route, err error) { 39 | routes = []Route{} 40 | err = DB.Find(&routes).Error 41 | return 42 | } 43 | 44 | // AddRoute add a new route 45 | func AddRoute(host, localIp, remoteHost string, remotePort, priority int, user, mailFrom, smtpAuthLogin, smtpAuthPasswd string) error { 46 | var err error 47 | route := new(Route) 48 | 49 | // detination host (not null) 50 | route.Host = strings.ToLower(strings.TrimSpace(host)) 51 | if route.Host == "" { 52 | return errors.New("host (user@host) must not be nul nor empty") 53 | } 54 | 55 | // localIP 56 | if strings.Index(localIp, "&") != -1 && strings.Index(localIp, "|") != -1 { 57 | return errors.New("mixed & and | are not allowed in routes") 58 | } 59 | if err = route.LocalIp.Scan(strings.TrimSpace(localIp)); err != nil { 60 | return err 61 | } 62 | 63 | // Remote host (not null) 64 | route.RemoteHost = strings.ToLower(strings.TrimSpace(remoteHost)) 65 | if route.RemoteHost == "" { 66 | return errors.New("remotHost must not b nul nor empty") 67 | } 68 | 69 | // Remote port 70 | if remotePort != 0 { 71 | route.RemotePort.Scan(remotePort) 72 | } else { 73 | if err = route.RemotePort.Scan(25); err != nil { 74 | return err 75 | } 76 | } 77 | 78 | // Priority 79 | if err = route.Priority.Scan(priority); err != nil { 80 | return err 81 | } 82 | 83 | // SMTPAUTH Login 84 | smtpAuthLogin = strings.TrimSpace(smtpAuthLogin) 85 | if smtpAuthLogin != "" { 86 | if err = route.SmtpAuthLogin.Scan(smtpAuthLogin); err != nil { 87 | return err 88 | } 89 | } 90 | 91 | // SMTPAUTH passwd 92 | smtpAuthPasswd = strings.TrimSpace(smtpAuthPasswd) 93 | if smtpAuthPasswd != "" { 94 | if err = route.SmtpAuthPasswd.Scan(smtpAuthPasswd); err != nil { 95 | return err 96 | } 97 | } 98 | 99 | // MailFrom 100 | mailFrom = strings.TrimSpace(mailFrom) 101 | if mailFrom != "" { 102 | if err = route.MailFrom.Scan(strings.ToLower(mailFrom)); err != nil { 103 | return err 104 | } 105 | } 106 | 107 | // SMTP user 108 | user = strings.TrimSpace(user) 109 | if user != "" { 110 | if err = route.User.Scan(user); err != nil { 111 | return err 112 | } 113 | } 114 | 115 | return DB.Create(route).Error 116 | } 117 | 118 | // DelRoute delete a route 119 | func DelRoute(id int64) error { 120 | r := Route{ 121 | Id: id, 122 | } 123 | return DB.Delete(&r).Error 124 | } 125 | 126 | // getRoutes returns matchingRoutes for the specified destination host 127 | func getRoutes(mailFrom, host, authUser string) (routes []Route, err error) { 128 | // Get mail from domain 129 | mailFromHost := "" 130 | p := strings.IndexRune(mailFrom, 64) 131 | if p != -1 { 132 | mailFromHost = strings.ToLower(mailFrom[p+1:]) 133 | } 134 | 135 | authUserHost := "" 136 | haveAuthUser := len(authUser) != 0 137 | // Si sous la forme user@domain on recupere le domaine 138 | if haveAuthUser { 139 | p := strings.IndexRune(authUser, 64) 140 | if p != -1 { 141 | authUserHost = strings.ToLower(authUser[p+1:]) 142 | } 143 | } 144 | 145 | // On teste si il y a une route correspondant à: authUser + host + mailFrom 146 | if haveAuthUser { 147 | if err = DB.Order("priority asc").Where("user=? and host=? and mail_from=?", authUser, host, mailFrom).Find(&routes).Error; err != nil { 148 | return 149 | } 150 | 151 | // On teste si il y a une route correspondant à: authUserHost + host + mailFrom 152 | if len(routes) == 0 { 153 | if len(authUserHost) != 0 { 154 | if err = DB.Order("priority asc").Where("user=? and host=? and mail_from=?", authUserHost, host, mailFrom).Find(&routes).Error; err != nil { 155 | return 156 | } 157 | } 158 | } 159 | 160 | // On teste si il y a une route correspondant à: authUser + host + mailFromHost 161 | if len(routes) == 0 { 162 | if err = DB.Order("priority asc").Where("user=? and host is null and mail_from is null", authUserHost).Find(&routes).Error; err != nil { 163 | return 164 | } 165 | } 166 | 167 | // On teste si il y a une route correspondant à: authUserHost + host + mailFromHost 168 | if len(routes) == 0 && len(authUserHost) != 0 { 169 | if err = DB.Order("priority asc").Where("user=? and host=? and mail_from=?", authUserHost, host, mailFromHost).Find(&routes).Error; err != nil { 170 | return 171 | } 172 | } 173 | 174 | // On teste si il y a une route correspondant à: authUser + host 175 | if len(routes) == 0 { 176 | if err = DB.Order("priority asc").Where("user=? and host=? and mail_from is null", authUser, host).Find(&routes).Error; err != nil { 177 | return 178 | } 179 | } 180 | 181 | // On teste si il y a une route correspondant à: authUserHost + host 182 | if len(routes) == 0 && len(authUserHost) != 0 { 183 | if err = DB.Order("priority asc").Where("user=? and host=? and mail_from is null", authUserHost, host).Find(&routes).Error; err != nil { 184 | return 185 | } 186 | } 187 | // On teste si il y a une route correspondant à: authUser 188 | if len(routes) == 0 { 189 | if err = DB.Order("priority asc").Where("user=? and host is null and mail_from is null", authUser).Find(&routes).Error; err != nil { 190 | return 191 | } 192 | } 193 | 194 | // On teste si il y a une route correspondant à: authUserHost 195 | if len(routes) == 0 && len(authUserHost) != 0 { 196 | if err = DB.Order("priority asc").Where("user=? and host is null and mail_from is null", authUserHost).Find(&routes).Error; err != nil { 197 | return 198 | } 199 | } 200 | } 201 | 202 | // On cherche les routes spécifiques à cet host 203 | if len(routes) == 0 { 204 | if err = DB.Order("priority asc").Where("host=? and user is null and mail_from is null", host).Find(&routes).Error; err != nil { 205 | return 206 | } 207 | } 208 | 209 | // Sinon on cherche une wildcard 210 | if len(routes) == 0 { 211 | if err = DB.Order("priority asc").Where("host=? and user is null and mail_from is null", "*").Find(&routes).Error; err != nil { 212 | return 213 | } 214 | } 215 | 216 | // Sinon on prends les MX 217 | if len(routes) == 0 { 218 | mxs, err := net.LookupMX(host) 219 | if err != nil { 220 | return routes, err 221 | } 222 | for _, mx := range mxs { 223 | routes = append(routes, Route{ 224 | RemoteHost: mx.Host, 225 | RemotePort: sql.NullInt64{25, true}, 226 | Priority: sql.NullInt64{int64(mx.Pref), true}, 227 | }) 228 | } 229 | } 230 | 231 | // On ajoute les IP locales 232 | for i, route := range routes { 233 | //Log.Debug(route) 234 | if !route.LocalIp.Valid || route.LocalIp.String == "" { 235 | routes[i].LocalIp.String = Cfg.GetLocalIps() 236 | } 237 | 238 | // Si il n'y a pas de port pour le remote host 239 | if !route.RemotePort.Valid { 240 | routes[i].RemotePort = sql.NullInt64{25, true} 241 | } 242 | 243 | // Pas de priorité on la met a 1 244 | if !route.Priority.Valid { 245 | routes[i].Priority = sql.NullInt64{1, true} 246 | } 247 | 248 | } 249 | 250 | // ordering routes 251 | // if multiple routes with same priorities we have to random their order 252 | byPriority := make(map[int64][]Route) 253 | for _, route := range routes { 254 | if _, ok := byPriority[route.Priority.Int64]; ok { 255 | byPriority[route.Priority.Int64] = append(byPriority[route.Priority.Int64], route) 256 | } else { 257 | byPriority[route.Priority.Int64] = []Route{route} 258 | } 259 | } 260 | priorities := make([]int, len(byPriority)) 261 | i := 0 262 | for p := range byPriority { 263 | priorities[i] = int(p) 264 | i++ 265 | } 266 | sort.Ints(priorities) 267 | 268 | routes = []Route{} 269 | rand.Seed(rand.Int63()) 270 | for k := range priorities { 271 | if len(byPriority[int64(priorities[k])]) > 1 { 272 | t := make([]Route, len(byPriority[int64(priorities[k])])) 273 | order := rand.Perm(len(byPriority[int64(priorities[k])])) 274 | for i, r := range byPriority[int64(priorities[k])] { 275 | t[order[i]] = r 276 | } 277 | routes = append(routes, t...) 278 | } else { 279 | routes = append(routes, byPriority[int64(priorities[k])][0]) 280 | } 281 | } 282 | Logger.Debug(routes) 283 | return 284 | } 285 | -------------------------------------------------------------------------------- /core/dkim.go: -------------------------------------------------------------------------------- 1 | // Provide tools for DKIM support 2 | 3 | package core 4 | 5 | import ( 6 | "crypto/rand" 7 | "crypto/rsa" 8 | "crypto/x509" 9 | "encoding/pem" 10 | "errors" 11 | "strconv" 12 | "strings" 13 | "time" 14 | 15 | "github.com/jinzhu/gorm" 16 | ) 17 | 18 | // DkimConfig represents DKIM configuration for a domain 19 | type DkimConfig struct { 20 | Id int64 21 | Domain string 22 | PubKey string `sql:"type:text;"` 23 | PrivKey string `sql:"type:text;"` 24 | Selector string 25 | Headers string 26 | } 27 | 28 | // DkimEnable enabled DKIM on domain 29 | func DkimEnable(domain string) (dkc *DkimConfig, err error) { 30 | domain = strings.ToLower(strings.TrimSpace(domain)) 31 | // Check if DKIM is alreadu enabled 32 | dkc = &DkimConfig{} 33 | err = DB.Where("domain = ?", domain).Find(dkc).Error 34 | if err != nil && err != gorm.ErrRecordNotFound { 35 | return nil, err 36 | } else if err == nil { 37 | return nil, errors.New("DKIM is already enabled on " + domain) 38 | } 39 | 40 | // Create new key pairs 41 | privKey, err := rsa.GenerateKey(rand.Reader, 1024) 42 | if err != nil { 43 | return nil, err 44 | } 45 | privKeyBlock := pem.Block{ 46 | Type: "RSA PRIVATE KEY", 47 | Headers: nil, 48 | Bytes: x509.MarshalPKCS1PrivateKey(privKey), 49 | } 50 | // privKeyPem 51 | privKeyPem := string(pem.EncodeToMemory(&privKeyBlock)) 52 | pubKeyDer, err := x509.MarshalPKIXPublicKey(&privKey.PublicKey) 53 | if err != nil { 54 | return nil, err 55 | } 56 | pubKeyBlock := pem.Block{ 57 | Type: "PUBLIC KEY", 58 | Headers: nil, 59 | Bytes: pubKeyDer, 60 | } 61 | t := strings.Split(string(pem.EncodeToMemory(&pubKeyBlock)), "\n") 62 | pubKey := strings.Join(t[1:len(t)-2], "") 63 | 64 | // selector: unique to prevent collision with existing record 65 | selector := strconv.FormatInt(time.Now().Unix(), 10) 66 | 67 | // save 68 | dkc = &DkimConfig{ 69 | Domain: domain, 70 | PubKey: pubKey, 71 | PrivKey: privKeyPem, 72 | Selector: selector, 73 | Headers: "", 74 | } 75 | 76 | err = DB.Save(dkc).Error 77 | return dkc, err 78 | } 79 | 80 | // DkimDisable Disable DKIM for domain domain by removing his 81 | // DkimConfig entry 82 | func DkimDisable(domain string) error { 83 | domain = strings.ToLower(strings.TrimSpace(domain)) 84 | // Check if DKIM is alreadu enabled 85 | err := DB.Where("domain = ?", domain).Delete(&DkimConfig{}).Error 86 | if err != nil && err == gorm.ErrRecordNotFound { 87 | return nil 88 | } 89 | return err 90 | } 91 | 92 | // DkimGetConfig returns DKIM config for domain domain 93 | func DkimGetConfig(domain string) (dkc *DkimConfig, err error) { 94 | dkc = &DkimConfig{} 95 | domain = strings.ToLower(domain) 96 | err = DB.Where("domain = ?", domain).First(dkc).Error 97 | if err != nil { 98 | if err == gorm.ErrRecordNotFound { 99 | return nil, nil 100 | } else { 101 | return nil, err 102 | } 103 | } 104 | return dkc, nil 105 | } 106 | -------------------------------------------------------------------------------- /core/errors.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "errors" 5 | ) 6 | 7 | var ( 8 | // ErrNonAsciiCharDetected when an email body does not contain only 7 bits ascii char 9 | ErrNonAsciiCharDetected = errors.New("email must contains only 7-bit ASCII characters") 10 | ) 11 | 12 | // ErrBadDsn when dsn is wrong 13 | func ErrBadDsn(err error) error { 14 | return errors.New("bad smtpd.dsn - " + err.Error()) 15 | } 16 | -------------------------------------------------------------------------------- /core/file_formatter.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "sort" 7 | "strings" 8 | "time" 9 | 10 | "github.com/sirupsen/logrus" 11 | ) 12 | 13 | var ( 14 | baseTimestamp time.Time 15 | ) 16 | 17 | //init inits timestamp on start time to be support of miniTS 18 | func init() { 19 | baseTimestamp = time.Now() 20 | } 21 | 22 | type FileFormatter struct { 23 | // Set to true to bypass checking for a TTY before outputting colors. 24 | ForceColors bool 25 | 26 | // Force disabling colors. 27 | DisableColors bool 28 | 29 | // Disable timestamp logging. useful when output is redirected to logging 30 | // system that already adds timestamps. 31 | DisableTimestamp bool 32 | 33 | // Enable logging the full timestamp when a TTY is attached instead of just 34 | // the time passed since beginning of execution. 35 | FullTimestamp bool 36 | 37 | // TimestampFormat to use for display when a full timestamp is printed 38 | TimestampFormat string 39 | 40 | // The fields are sorted by default for a consistent output. For applications 41 | // that log extremely frequently and don't use the JSON formatter this may not 42 | // be desired. 43 | DisableSorting bool 44 | } 45 | 46 | //Format formats the log entry 47 | func (f *FileFormatter) Format(entry *logrus.Entry) ([]byte, error) { 48 | var b *bytes.Buffer 49 | var keys []string = make([]string, 0, len(entry.Data)) 50 | for k := range entry.Data { 51 | keys = append(keys, k) 52 | } 53 | 54 | if !f.DisableSorting { 55 | sort.Strings(keys) 56 | } 57 | if entry.Buffer != nil { 58 | b = entry.Buffer 59 | } else { 60 | b = &bytes.Buffer{} 61 | } 62 | 63 | prefixFieldClashes(entry.Data) 64 | 65 | timestampFormat := f.TimestampFormat 66 | if timestampFormat == "" { 67 | timestampFormat = time.RFC3339 68 | } 69 | 70 | levelText := strings.ToUpper(entry.Level.String())[0:4] 71 | 72 | if !f.FullTimestamp { 73 | fmt.Fprintf(b, "%s[%04d] %-44s ", levelText, miniTS(), entry.Message) 74 | } else { 75 | fmt.Fprintf(b, "%s[%s] %-44s ", levelText, entry.Time.Format(timestampFormat), entry.Message) 76 | } 77 | for _, k := range keys { 78 | v := entry.Data[k] 79 | fmt.Fprintf(b, " %s=", k) 80 | f.appendValue(b, v) 81 | } 82 | 83 | b.WriteByte('\n') 84 | return b.Bytes(), nil 85 | } 86 | 87 | //miniTS return the mini timestamp (ie seconds since logger start) 88 | func miniTS() int { 89 | return int(time.Since(baseTimestamp) / time.Second) 90 | } 91 | 92 | //needsQuoting quotes string if needed 93 | func needsQuoting(text string) bool { 94 | for _, ch := range text { 95 | if !((ch >= 'a' && ch <= 'z') || 96 | (ch >= 'A' && ch <= 'Z') || 97 | (ch >= '0' && ch <= '9') || 98 | ch == '-' || ch == '.') { 99 | return true 100 | } 101 | } 102 | return false 103 | } 104 | 105 | //appendValue add value to log line output 106 | func (f *FileFormatter) appendValue(b *bytes.Buffer, value interface{}) { 107 | switch value := value.(type) { 108 | case string: 109 | if !needsQuoting(value) { 110 | b.WriteString(value) 111 | } else { 112 | fmt.Fprintf(b, "%q", value) 113 | } 114 | case error: 115 | errmsg := value.Error() 116 | if !needsQuoting(errmsg) { 117 | b.WriteString(errmsg) 118 | } else { 119 | fmt.Fprintf(b, "%q", errmsg) 120 | } 121 | default: 122 | fmt.Fprint(b, value) 123 | } 124 | } 125 | 126 | //appendKeyValue add key=value to log line output 127 | func (f *FileFormatter) appendKeyValue(b *bytes.Buffer, key string, value interface{}) { 128 | 129 | b.WriteString(key) 130 | b.WriteByte('=') 131 | f.appendValue(b, value) 132 | b.WriteByte(' ') 133 | } 134 | 135 | //prefixFieldClashes avoid conflict for time / msg / level indexes 136 | func prefixFieldClashes(data logrus.Fields) { 137 | if t, ok := data["time"]; ok { 138 | data["fields.time"] = t 139 | } 140 | 141 | if m, ok := data["msg"]; ok { 142 | data["fields.msg"] = m 143 | } 144 | 145 | if l, ok := data["level"]; ok { 146 | data["fields.level"] = l 147 | } 148 | } 149 | 150 | //printColored need for logrus.TextFormatter compatibility 151 | func (f *FileFormatter) printColored(b *bytes.Buffer, entry *logrus.Entry, keys []string, timestampFormat string) { 152 | } 153 | -------------------------------------------------------------------------------- /core/local.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | 7 | "github.com/jinzhu/gorm" 8 | ) 9 | 10 | // Check if it's a local delivery 11 | func isLocalDelivery(rcpt string) (bool, error) { 12 | t := strings.Split(rcpt, "@") 13 | if len(t) != 2 { 14 | return false, errors.New("bar rcpt syntax: " + rcpt) 15 | } 16 | 17 | // check rcpthost 18 | rcpthost, err := RcpthostGet(t[1]) 19 | if err == gorm.ErrRecordNotFound { 20 | return false, nil 21 | } else if err != nil { 22 | return false, err 23 | } 24 | return rcpthost.IsLocal, nil 25 | } 26 | 27 | // IsValidLocalRcpt checks if rcpt is a valid local destination 28 | // Mailbox (or wildcard) 29 | // Alias 30 | // catchall 31 | func IsValidLocalRcpt(rcpt string) (bool, error) { 32 | // mailbox 33 | u, err := UserGetByLogin(rcpt) 34 | if err != nil && err != gorm.ErrRecordNotFound { 35 | return false, err 36 | } 37 | if err == nil && u.HaveMailbox { 38 | return true, nil 39 | } 40 | // email alias 41 | exists, err := AliasExists(rcpt) 42 | if err != nil { 43 | return false, err 44 | } 45 | if exists { 46 | return true, nil 47 | } 48 | // domain alias 49 | localDom := strings.Split(rcpt, "@") 50 | if len(localDom) != 2 { 51 | return false, errors.New("bad address format in IsValidLocalRcpt. Got " + rcpt) 52 | } 53 | exists, err = AliasExists(localDom[1]) 54 | if err != nil { 55 | return false, err 56 | } 57 | if exists { 58 | alias, err := AliasGet(localDom[1]) 59 | if err != nil { 60 | return false, err 61 | } 62 | return IsValidLocalRcpt(localDom[0] + "@" + alias.DeliverTo) 63 | } 64 | // Catchall 65 | u, err = UserGetCatchallForDomain(localDom[1]) 66 | if err != nil && err != gorm.ErrRecordNotFound { 67 | return false, err 68 | } 69 | if err == nil { 70 | return true, nil 71 | } 72 | return false, nil 73 | } 74 | -------------------------------------------------------------------------------- /core/logger.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | // NSQLogger is a logger for Nsq 4 | type NSQLogger struct{} 5 | 6 | // NewNSQLogger return a new NSQLogger 7 | func NewNSQLogger() *NSQLogger { 8 | return new(NSQLogger) 9 | } 10 | 11 | // Output implements nsq.logger.Output interface 12 | func (n *NSQLogger) Output(calldepth int, s string) error { 13 | Logger.Debug(s) 14 | return nil 15 | } 16 | -------------------------------------------------------------------------------- /core/mailbox.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "errors" 5 | "net/mail" 6 | "strings" 7 | 8 | "github.com/jinzhu/gorm" 9 | ) 10 | 11 | type Mailbox struct { 12 | Id int64 13 | LocalPart string 14 | DomainPart string 15 | } 16 | 17 | // MailboxAdd adds a new mailbox 18 | func MailboxAdd(mailbox string) error { 19 | mailbox = strings.ToLower(mailbox) 20 | address, err := mail.ParseAddress(mailbox) 21 | if err != nil { 22 | return errors.New("Bad mailbox format: " + mailbox) 23 | } 24 | t := strings.Split(address.Address, "@") 25 | mb := Mailbox{ 26 | LocalPart: t[0], 27 | DomainPart: t[1], 28 | } 29 | // hostname must be in rcpthost 30 | b, err := IsInRcptHost(mb.DomainPart) 31 | if err != nil { 32 | return err 33 | } 34 | if !b { 35 | return errors.New("Domain " + mb.DomainPart + " doesn't exists in rcpthosts. You must add it before create mailboxes linked to that domain.") 36 | } 37 | 38 | // exists ? 39 | b, err = MailboxExists(mailbox) 40 | if err != nil { 41 | return err 42 | } 43 | if b { 44 | return errors.New("Mailbox " + mailbox + " already exists.") 45 | } 46 | return DB.Create(&Mailbox{ 47 | LocalPart: t[0], 48 | DomainPart: t[1], 49 | }).Error 50 | } 51 | 52 | // MailboxDel delete Mailbox 53 | // TODO: supprimer tout ce qui est associé a cette boite 54 | func MailboxDel(mailbox string) error { 55 | mailbox = strings.ToLower(mailbox) 56 | address, err := mail.ParseAddress(mailbox) 57 | if err != nil { 58 | return errors.New("Bad mailbox format: " + mailbox) 59 | } 60 | t := strings.Split(address.Address, "@") 61 | return DB.Where("local_part=? and domain_part=?", t[0], t[1]).Delete(&Mailbox{}).Error 62 | } 63 | 64 | // MailboxExists checks if mailbox exist 65 | func MailboxExists(mailbox string) (bool, error) { 66 | t := strings.Split(mailbox, "@") 67 | if len(t) != 2 { 68 | return false, errors.New("Bad mailbox format: " + mailbox) 69 | } 70 | err := DB.Where("local_part=? and domain_part=?", t[0], t[1]).Find(&Mailbox{}).Error 71 | if err == nil { 72 | return true, nil 73 | } 74 | if err != gorm.ErrRecordNotFound { 75 | return false, err 76 | } 77 | return false, nil 78 | } 79 | 80 | // MailboxList return all mailboxes 81 | func MailboxList() (mailboxes []Mailbox, err error) { 82 | mailboxes = []Mailbox{} 83 | err = DB.Find(&mailboxes).Error 84 | return 85 | } 86 | 87 | func (m *Mailbox) Put() { 88 | return 89 | } 90 | -------------------------------------------------------------------------------- /core/mailqueue.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "strings" 8 | "sync" 9 | "time" 10 | 11 | "github.com/toorop/tmail/message" 12 | ) 13 | 14 | // QMessage represents a message in queue 15 | type QMessage struct { 16 | sync.Mutex 17 | Id int64 18 | Uuid string // Unique ID common to all QMessage, representing the queued ID of the message 19 | MailFrom string 20 | AuthUser string // Si il y a eu authentification SMTP contient le login/user sert pour le routage 21 | RcptTo string 22 | MessageId string 23 | Host string 24 | LastUpdate time.Time 25 | AddedAt time.Time 26 | NextDeliveryScheduledAt time.Time 27 | Status uint32 // 0 delivery in progress, 1 to be discarded, 2 scheduled, 3 to be bounced 28 | DeliveryFailedCount uint32 29 | } 30 | 31 | // Delete delete message from queue 32 | func (q *QMessage) Delete() error { 33 | q.Lock() 34 | defer q.Unlock() 35 | var err error 36 | // remove from DB 37 | if err = DB.Delete(q).Error; err != nil { 38 | return err 39 | } 40 | // If there is no other reference in DB, remove raw message from store 41 | var c uint 42 | if err = DB.Model(QMessage{}).Where("`uuid` = ?", q.Uuid).Count(&c).Error; err != nil { 43 | return err 44 | } 45 | if c != 0 { 46 | return nil 47 | } 48 | /*qStore, err := NewStore(Cfg.GetStoreDriver(), Cfg.GetStoreSource()) 49 | if err != nil { 50 | return err 51 | }*/ 52 | err = Store.Del(q.Uuid) 53 | // Si le fichier n'existe pas ce n'est pas une véritable erreur 54 | if err != nil && strings.Contains(err.Error(), "no such file") { 55 | err = nil 56 | } 57 | return err 58 | } 59 | 60 | // UpdateFromDb update message from DB 61 | func (q *QMessage) UpdateFromDb() error { 62 | q.Lock() 63 | defer q.Unlock() 64 | return DB.First(q, q.Id).Error 65 | } 66 | 67 | // SaveInDb save qMessage in DB 68 | func (q *QMessage) SaveInDb() error { 69 | q.Lock() 70 | defer q.Unlock() 71 | q.LastUpdate = time.Now() 72 | return DB.Save(q).Error 73 | } 74 | 75 | // Discard mark message as being discarded on next delivery attemp 76 | func (q *QMessage) Discard() error { 77 | if q.Status == 0 { 78 | return errors.New("delivery in progress, message status can't be changed") 79 | } 80 | q.Lock() 81 | q.Status = 1 82 | q.Unlock() 83 | return q.SaveInDb() 84 | } 85 | 86 | // Bounce mark message as being bounced on next delivery attemp 87 | func (q *QMessage) Bounce() error { 88 | if q.Status == 0 { 89 | return errors.New("delivery in progress, message status can't be changed") 90 | } 91 | q.Lock() 92 | q.Status = 3 93 | q.Unlock() 94 | return q.SaveInDb() 95 | } 96 | 97 | // QueueGetMessageById return a message from is key 98 | func QueueGetMessageById(id int64) (msg QMessage, err error) { 99 | msg = QMessage{} 100 | err = DB.Where("id = ?", id).First(&msg).Error 101 | /*if err != nil && err == gorm.RecordNotFound { 102 | err = errors.New("not found") 103 | }*/ 104 | return 105 | } 106 | 107 | // QueueGetExpiredMessages return expired messages from DB 108 | func QueueGetExpiredMessages() (messages []QMessage, err error) { 109 | messages = []QMessage{} 110 | from := time.Now().Add(-24 * time.Hour) 111 | err = DB.Where("next_delivery_scheduled_at < ?", from).Find(&messages).Error 112 | return 113 | } 114 | 115 | // QueueAddMessage add a new mail in queue 116 | func QueueAddMessage(rawMess *[]byte, envelope message.Envelope, authUser string) (uuid string, err error) { 117 | qStore, err := NewStore(Cfg.GetStoreDriver(), Cfg.GetStoreSource()) 118 | if err != nil { 119 | return 120 | } 121 | 122 | uuid, err = NewUUID() 123 | if err != nil { 124 | return 125 | } 126 | err = qStore.Put(uuid, bytes.NewReader(*rawMess)) 127 | if err != nil { 128 | return 129 | } 130 | 131 | messageId := message.RawGetMessageId(rawMess) 132 | 133 | cloop := 0 134 | qmessages := []QMessage{} 135 | for _, rcptTo := range envelope.RcptTo { 136 | qm := QMessage{ 137 | Uuid: uuid, 138 | AuthUser: authUser, 139 | MailFrom: envelope.MailFrom, 140 | RcptTo: rcptTo, 141 | MessageId: string(messageId), 142 | Host: message.GetHostFromAddress(rcptTo), 143 | LastUpdate: time.Now(), 144 | AddedAt: time.Now(), 145 | NextDeliveryScheduledAt: time.Now(), 146 | Status: 2, 147 | DeliveryFailedCount: 0, 148 | } 149 | 150 | // create record in db 151 | err = DB.Create(&qm).Error 152 | if err != nil { 153 | if cloop == 0 { 154 | qStore.Del(uuid) 155 | } 156 | return 157 | } 158 | cloop++ 159 | qmessages = append(qmessages, qm) 160 | } 161 | 162 | // publish qmessage 163 | // TODO: to avoid the copy of the Lock -> qmsg.Publish() 164 | for _, qmsg := range qmessages { 165 | var jMsg []byte 166 | jMsg, err = json.Marshal(qmsg) 167 | if err != nil { 168 | if cloop == 1 { 169 | qStore.Del(uuid) 170 | } 171 | DB.Delete(&qmsg) 172 | return 173 | } 174 | // queue local | queue remote 175 | err = NsqQueueProducer.Publish("todeliver", jMsg) 176 | if err != nil { 177 | if cloop == 1 { 178 | qStore.Del(uuid) 179 | } 180 | DB.Delete(&qmsg) 181 | return 182 | } 183 | } 184 | return 185 | } 186 | 187 | // QueueListMessages return all messages in queue 188 | func QueueListMessages() ([]QMessage, error) { 189 | messages := []QMessage{} 190 | err := DB.Find(&messages).Error 191 | return messages, err 192 | } 193 | 194 | // QueueCount rerurn the number of message in queue 195 | func QueueCount() (c uint32, err error) { 196 | c = 0 197 | err = DB.Model(QMessage{}).Count(&c).Error 198 | return 199 | } 200 | -------------------------------------------------------------------------------- /core/plugin.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | func init() { 4 | TmailPlugins = make(map[string][]TmailPlugin) 5 | SMTPdPlugins = make(map[string][]SMTPdPlugin) 6 | DeliverdPlugins = make(map[string][]DeliverdPlugin) 7 | } 8 | 9 | // Tmail core plugin 10 | 11 | // TmailPlugin base plugin for hooks: 12 | // - postinit 13 | type TmailPlugin func() 14 | 15 | // TmailPlugins is a map of plugin 16 | var TmailPlugins map[string][]TmailPlugin 17 | 18 | // RegisterPlugin registers a new plugin 19 | func RegisterPlugin(hook string, plugin TmailPlugin) { 20 | TmailPlugins[hook] = append(TmailPlugins[hook], plugin) 21 | } 22 | 23 | func execTmailPlugins(hook string) { 24 | if plugins, found := TmailPlugins[hook]; found { 25 | for _, plugin := range plugins { 26 | plugin() 27 | } 28 | } 29 | return 30 | } 31 | 32 | // Smtpd plugins 33 | 34 | // SMTPdPlugin is the type for SMTPd plugins 35 | type SMTPdPlugin func(s *SMTPServerSession) bool 36 | 37 | // SMTPdPlugins is a map of SMTPd plugins 38 | var SMTPdPlugins map[string][]SMTPdPlugin 39 | 40 | // RegisterSMTPdPlugin registers a new smtpd plugin 41 | func RegisterSMTPdPlugin(hook string, plugin SMTPdPlugin) { 42 | SMTPdPlugins[hook] = append(SMTPdPlugins[hook], plugin) 43 | } 44 | 45 | func execSMTPdPlugins(hook string, s *SMTPServerSession) bool { 46 | if plugins, found := SMTPdPlugins[hook]; found { 47 | for _, plugin := range plugins { 48 | if plugin(s) { 49 | return true 50 | } 51 | return false 52 | } 53 | } 54 | return false 55 | } 56 | 57 | // Deliverd plugins 58 | 59 | // DeliverdPlugin type for deliverd plugin 60 | type DeliverdPlugin func(d *Delivery) bool 61 | 62 | // DeliverdPlugins map of deliverd plugins 63 | var DeliverdPlugins map[string][]DeliverdPlugin 64 | 65 | // RegisterDeliverdPlugin registers plugin for deliverd hooks 66 | func RegisterDeliverdPlugin(hook string, plugin DeliverdPlugin) { 67 | DeliverdPlugins[hook] = append(DeliverdPlugins[hook], plugin) 68 | } 69 | 70 | func execDeliverdPlugins(hook string, d *Delivery) bool { 71 | if plugins, found := DeliverdPlugins[hook]; found { 72 | for _, plugin := range plugins { 73 | if !plugin(d) { 74 | return false 75 | } 76 | } 77 | } 78 | return true 79 | } 80 | -------------------------------------------------------------------------------- /core/rcpthost.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | 7 | "github.com/jinzhu/gorm" 8 | ) 9 | 10 | // RcptHost represents a hostname that tmail have to handle mails for (=local domains) 11 | type RcptHost struct { 12 | Id int64 13 | Hostname string `sql:"unique"` 14 | IsLocal bool `sql:"default:false"` 15 | IsAlias bool `sql:"default:false"` 16 | } 17 | 18 | // IsInRcptHost checks if domain is in the RcptHost list (-> relay authorized) 19 | func IsInRcptHost(hostname string) (bool, error) { 20 | err := DB.Where("hostname = ?", hostname).First(&RcptHost{}).Error 21 | if err == nil { 22 | return true, nil 23 | } 24 | if err != gorm.ErrRecordNotFound { 25 | return false, err 26 | } 27 | return false, nil 28 | } 29 | 30 | // RcpthostGet return a rcpthost 31 | func RcpthostGet(hostname string) (rcpthost RcptHost, err error) { 32 | err = DB.Where("hostname = ?", hostname).First(&rcpthost).Error 33 | return 34 | } 35 | 36 | // RcpthostAdd add hostname to rcpthosts 37 | func RcpthostAdd(hostname string, isLocal, isAlias bool) error { 38 | if len(hostname) > 256 { 39 | return errors.New("hostname must have less than 256 chars") 40 | } 41 | // to lower 42 | hostname = strings.ToLower(hostname) 43 | 44 | // TODO: validate hostname 45 | 46 | // domain already in rcpthosts ? 47 | var count int 48 | if err := DB.Model(RcptHost{}).Where("hostname = ?", hostname).Count(&count).Error; err != nil { 49 | return err 50 | } 51 | if count != 0 { 52 | return errors.New("Hostname " + hostname + " already in rcpthosts") 53 | } 54 | h := RcptHost{ 55 | Hostname: hostname, 56 | IsLocal: isLocal, 57 | IsAlias: isAlias, 58 | } 59 | return DB.Save(&h).Error 60 | } 61 | 62 | // RcpthostDel delete a hostname from rcpthosts list 63 | func RcpthostDel(hostname string) error { 64 | //var err error 65 | hostname = strings.ToLower(hostname) 66 | // hostname exits ? 67 | /*var count int 68 | if err = DB.Model(RcptHost{}).Where("hostname = ?", hostname).Count(&count).Error; err != nil { 69 | return err 70 | } 71 | if count == 0 { 72 | return errors.New("Hostname " + hostname + " doesn't exists.") 73 | }*/ 74 | return DB.Where("hostname = ?", hostname).Delete(&RcptHost{}).Error 75 | } 76 | 77 | // RcpthostGetAll return hostnames in rcpthosts 78 | func RcpthostGetAll() (hostnames []RcptHost, err error) { 79 | hostnames = []RcptHost{} 80 | err = DB.Find(&hostnames).Error 81 | return 82 | } 83 | -------------------------------------------------------------------------------- /core/scope.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | // TODO: nsq logger 4 | 5 | import ( 6 | "errors" 7 | "io" 8 | "io/ioutil" 9 | "log" 10 | "os" 11 | "path" 12 | "time" 13 | 14 | "github.com/boltdb/bolt" 15 | _ "github.com/go-sql-driver/mysql" 16 | "github.com/jinzhu/gorm" 17 | _ "github.com/lib/pq" 18 | "github.com/nsqio/go-nsq" 19 | "github.com/sirupsen/logrus" 20 | _ "github.com/toorop/go-sqlite3" 21 | "github.com/toorop/gopenstack/context" 22 | "github.com/toorop/gopenstack/identity" 23 | ) 24 | 25 | const ( 26 | // Time822 formt time for RFC 822 27 | Time822 = "02 Jan 2006 15:04:05 -0700" // "02 Jan 06 15:04 -0700" 28 | ) 29 | 30 | var ( 31 | // Version is tamil version 32 | Version string 33 | Cfg *Config 34 | DB *gorm.DB 35 | Bolt *bolt.DB 36 | //Log *Logger 37 | Logger *logrus.Logger 38 | NsqQueueProducer *nsq.Producer 39 | SmtpSessionsCount int 40 | ChSmtpSessionsCount chan int 41 | DeliverdConcurrencyLocalCount int 42 | DeliverdConcurrencyRemoteCount int 43 | ChDeliverdConcurrencyRemoteCount chan int 44 | Store Storer 45 | ) 46 | 47 | // Bootstrap DB, config,... 48 | // TODO check validity of each element 49 | func Bootstrap() (err error) { 50 | // Load config 51 | Cfg, err = InitConfig("tmail") 52 | if err != nil { 53 | return 54 | } 55 | 56 | // linit logger 57 | var out io.Writer 58 | 59 | logPath := Cfg.GetLogPath() 60 | Logger = logrus.New() 61 | //customFormatter := new(logrus.TextFormatter) 62 | //customFormatter := new(FileFormatter) 63 | if logPath == "stdout" { 64 | out = os.Stdout 65 | f := new(logrus.TextFormatter) 66 | f.TimestampFormat = time.RFC3339Nano 67 | f.FullTimestamp = true 68 | Logger.Formatter = f 69 | 70 | } else if logPath == "discard" { 71 | out = ioutil.Discard 72 | f := new(logrus.TextFormatter) 73 | f.TimestampFormat = time.RFC3339Nano 74 | f.FullTimestamp = true 75 | Logger.Formatter = f 76 | } else { 77 | file := path.Join(logPath, "current.log") 78 | out, err = os.OpenFile(file, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) 79 | if err != nil { 80 | return 81 | } 82 | f := new(FileFormatter) 83 | f.TimestampFormat = time.RFC3339Nano 84 | f.FullTimestamp = true 85 | Logger.Formatter = f 86 | } 87 | 88 | if Cfg.GetDebugEnabled() { 89 | Logger.Level = logrus.DebugLevel 90 | } else { 91 | Logger.Level = logrus.InfoLevel 92 | } 93 | Logger.Out = out 94 | Logger.Debug("Logger initialized") 95 | 96 | // Init DB 97 | DB, err = gorm.Open(Cfg.GetDbDriver(), Cfg.GetDbSource()) 98 | if err != nil { 99 | return 100 | } 101 | DB.SetLogger(Logger) 102 | DB.LogMode(Cfg.GetDebugEnabled()) 103 | 104 | // ping DB 105 | if DB.DB().Ping() != nil { 106 | return errors.New("I could not access to database " + Cfg.GetDbDriver() + " " + Cfg.GetDbSource()) 107 | } 108 | 109 | // TODO remove from bootstrap 110 | // init NSQ MailQueueProducer (Nmqp) 111 | if Cfg.GetLaunchSmtpd() { 112 | err = initMailQueueProducer() 113 | if err != nil { 114 | return err 115 | } 116 | } 117 | 118 | // SMTP in sessions counter 119 | SmtpSessionsCount = 0 120 | ChSmtpSessionsCount = make(chan int) 121 | go func() { 122 | for { 123 | SmtpSessionsCount += <-ChSmtpSessionsCount 124 | } 125 | }() 126 | 127 | // Deliverd remote process 128 | DeliverdConcurrencyRemoteCount = 0 129 | ChDeliverdConcurrencyRemoteCount = make(chan int) 130 | go func() { 131 | for { 132 | DeliverdConcurrencyRemoteCount += <-ChDeliverdConcurrencyRemoteCount 133 | } 134 | }() 135 | 136 | // openstack 137 | if Cfg.GetOpenstackEnable() { 138 | if !context.Keyring.IsPopulate() { 139 | return errors.New("No credentials found from ENV. See http://docs.openstack.org/cli-reference/content/cli_openrc.html") 140 | } 141 | // Do auth 142 | err = identity.DoAuth() 143 | if err != nil { 144 | return err 145 | } 146 | // auto update Token 147 | identity.AutoUpdate(30, new(log.Logger)) 148 | } 149 | 150 | // init store 151 | Store, err = NewStore(Cfg.GetStoreDriver(), Cfg.GetStoreSource()) 152 | if err != nil { 153 | return err 154 | } 155 | // TODO gestion erreur 156 | execTmailPlugins("postinit") 157 | 158 | return 159 | } 160 | 161 | // InitBolt init bolt 162 | func InitBolt() error { 163 | var err error 164 | // init Bolt DB 165 | Bolt, err = bolt.Open(Cfg.GetBoltFile(), 0600, nil) 166 | if err != nil { 167 | return err 168 | } 169 | // create buckets if not exists 170 | return Bolt.Update(func(tx *bolt.Tx) error { 171 | if _, err = tx.CreateBucketIfNotExists([]byte("koip")); err != nil { 172 | return err 173 | } 174 | return nil 175 | }) 176 | } 177 | 178 | // initMailQueueProducer init producer for queue 179 | func initMailQueueProducer() (err error) { 180 | nsqCfg := nsq.NewConfig() 181 | nsqCfg.UserAgent = "tmail.queue" 182 | NsqQueueProducer, err = nsq.NewProducer("127.0.0.1:4150", nsqCfg) 183 | if Cfg.GetDebugEnabled() { 184 | NsqQueueProducer.SetLogger(NewNSQLogger(), nsq.LogLevelDebug) 185 | } else { 186 | NsqQueueProducer.SetLogger(NewNSQLogger(), nsq.LogLevelError) 187 | } 188 | return err 189 | } 190 | -------------------------------------------------------------------------------- /core/smtpd.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "crypto/tls" 5 | "log" 6 | "net" 7 | "path" 8 | ) 9 | 10 | // Smtpd SMTP Server 11 | type Smtpd struct { 12 | dsn dsn 13 | } 14 | 15 | // NewSmtpd returns a new SmtpServer 16 | func NewSmtpd(d dsn) *Smtpd { 17 | return &Smtpd{d} 18 | } 19 | 20 | // ListenAndServe launch server 21 | func (s *Smtpd) ListenAndServe() { 22 | var listener net.Listener 23 | var err error 24 | var tlsConfig *tls.Config 25 | // SSL ? 26 | if s.dsn.ssl { 27 | cert, err := tls.LoadX509KeyPair(path.Join(GetBasePath(), "ssl/server.crt"), path.Join(GetBasePath(), "ssl/server.key")) 28 | if err != nil { 29 | log.Fatalln("unable to load SSL keys for smtpd.", "dsn:", s.dsn.tcpAddr, "ssl", s.dsn.ssl, "err:", err) 30 | } 31 | // TODO: http://fastah.blackbuck.mobi/blog/securing-https-in-go/ 32 | tlsConfig = &tls.Config{ 33 | Certificates: []tls.Certificate{cert}, 34 | InsecureSkipVerify: true, 35 | } 36 | listener, err = tls.Listen(s.dsn.tcpAddr.Network(), s.dsn.tcpAddr.String(), tlsConfig) 37 | if err != nil { 38 | log.Fatalln("unable to create TLS listener.", err) 39 | } 40 | } else { 41 | listener, err = net.Listen(s.dsn.tcpAddr.Network(), s.dsn.tcpAddr.String()) 42 | if err != nil { 43 | log.Fatalln("unable to create listener") 44 | } 45 | } 46 | if err != nil { 47 | log.Fatalln(err) 48 | } else { 49 | defer listener.Close() 50 | for { 51 | conn, error := listener.Accept() 52 | if error != nil { 53 | log.Println("Client error: ", error) 54 | } else { 55 | go func(conn net.Conn) { 56 | ChSmtpSessionsCount <- 1 57 | defer func() { ChSmtpSessionsCount <- -1 }() 58 | sss, err := NewSMTPServerSession(conn, s.dsn.ssl) 59 | if err != nil { 60 | log.Println("unable to get new SmtpServerSession.", err) 61 | } else { 62 | sss.handle() 63 | } 64 | }(conn) 65 | } 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /core/smtpd_dsn.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "errors" 5 | "net" 6 | "strconv" 7 | "strings" 8 | ) 9 | 10 | // DSN IP port and secured (none, tls, ssl) 11 | type dsn struct { 12 | tcpAddr net.TCPAddr 13 | ssl bool 14 | } 15 | 16 | // String return string representation of a dsn 17 | func (d *dsn) String() string { 18 | s := "" 19 | if d.ssl { 20 | s = " SSL" 21 | } 22 | return d.tcpAddr.String() + s 23 | } 24 | 25 | //getDsnsFromString Get dsn string from config and returns slice of dsn struct 26 | func GetDsnsFromString(dsnsStr string) (dsns []dsn, err error) { 27 | if len(dsnsStr) == 0 { 28 | return dsns, errors.New("your smtpd.dsn string is empty") 29 | } 30 | // clean 31 | dsnsStr = strings.ToLower(dsnsStr) 32 | 33 | // parse 34 | for _, dsnStr := range strings.Split(dsnsStr, ";") { 35 | if strings.Count(dsnStr, ":") != 2 { 36 | return dsns, errors.New("bad smtpd.dsn " + dsnStr + " found in config" + dsnsStr) 37 | } 38 | t := strings.Split(dsnStr, ":") 39 | // ip & port valid ? 40 | tcpAddr, err := net.ResolveTCPAddr("tcp", net.JoinHostPort(t[0], t[1])) 41 | if err != nil { 42 | return dsns, errors.New("bad IP:Port found in dsn" + dsnStr + "from config dsn" + dsnsStr) 43 | } 44 | ssl, err := strconv.ParseBool(t[2]) 45 | if err != nil { 46 | return dsns, ErrBadDsn(err) 47 | } 48 | dsns = append(dsns, dsn{*tcpAddr, ssl}) 49 | } 50 | return 51 | } 52 | -------------------------------------------------------------------------------- /core/smtpd_relay_ip.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "errors" 5 | "net" 6 | "strings" 7 | 8 | "github.com/jinzhu/gorm" 9 | ) 10 | 11 | // relayOkIp represents an IP that can use SMTP for relaying 12 | type RelayIpOk struct { 13 | Id int64 14 | Ip string `sql:"unique"` 15 | } 16 | 17 | // remoteIpCanUseSmtp checks if an IP can relay 18 | func IpCanRelay(ip net.Addr) (bool, error) { 19 | err := DB.Where("ip = ?", ip.String()[:strings.Index(ip.String(), ":")]).Find(&RelayIpOk{}).Error 20 | if err == nil { 21 | return true, nil 22 | } 23 | if err != gorm.ErrRecordNotFound { 24 | return false, err 25 | } 26 | return false, nil 27 | } 28 | 29 | // relayipAdd authorize IP to relay through tmail 30 | func RelayIpAdd(ip string) error { 31 | // input validation 32 | if net.ParseIP(ip) == nil { 33 | return errors.New("Invalid IP: " + ip) 34 | } 35 | rip := RelayIpOk{ 36 | Ip: ip, 37 | } 38 | return DB.Save(&rip).Error 39 | } 40 | 41 | // RelayIpList return all IPs authorized to relay through tmail 42 | func RelayIpGetAll() (ips []RelayIpOk, err error) { 43 | ips = []RelayIpOk{} 44 | err = DB.Find(&ips).Error 45 | return 46 | } 47 | 48 | // RelayIpDel remove ip from authorized IP 49 | func RelayIpDel(ip string) error { 50 | // input validation 51 | if net.ParseIP(ip) == nil { 52 | return errors.New("Invalid IP: " + ip) 53 | } 54 | return DB.Where("ip = ?", ip).Delete(&RelayIpOk{}).Error 55 | } 56 | -------------------------------------------------------------------------------- /core/smtpd_user.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | /* 4 | 5 | import ( 6 | "errors" 7 | "golang.org/x/crypto/bcrypt" 8 | ) 9 | 10 | type SmtpUser struct { 11 | Login string 12 | Passwd string 13 | AuthRelay bool 14 | } 15 | 16 | // NewSmtpUser return a new authentificated smtp user 17 | func NewSmtpUser(login, passwd string) (user *SmtpUser, err error) { 18 | user = &SmtpUser{} 19 | // verification des entres 20 | if len(login) == 0 || len(passwd) == 0 { 21 | err := errors.New("login or passwd is empty") 22 | return nil, err 23 | } 24 | 25 | err = DB.Where("login = ?", login).First(user).Error 26 | if err != nil { 27 | return nil, err 28 | } 29 | // Encoding passwd 30 | //hashed, err := bcrypt.GenerateFromPassword([]byte(passwd), 10) 31 | //log.Println(string(hashed), err) 32 | 33 | // Check passwd 34 | err = bcrypt.CompareHashAndPassword([]byte(user.Passwd), []byte(passwd)) 35 | return 36 | } 37 | 38 | // check if user can relay throught this server 39 | // TODO je pense qy'il faudrait mettre le destinataires pour les limitation par destinataion 40 | func (s *SmtpUser) canUseSmtp() (bool, error) { 41 | return s.AuthRelay, nil 42 | } 43 | 44 | // AddUser add a new user 45 | func AddUser(login, passwd string, authRelay bool) (err error) { 46 | // login must be < 257 char 47 | if len(login) > 256 { 48 | return errors.New("login must have less than 256 chars") 49 | } 50 | // passwd > 6 char 51 | if len(passwd) < 6 { 52 | return errors.New("password must be at least 6 chars lenght") 53 | } 54 | // users exits ? 55 | var count int 56 | if err = DB.Model(SmtpUser{}).Where("login = ?", login).Count(&count).Error; err != nil { 57 | return err 58 | } 59 | if count != 0 { 60 | return errors.New("User " + login + " already exists") 61 | } 62 | 63 | hashed, err := bcrypt.GenerateFromPassword([]byte(passwd), 10) 64 | if err != nil { 65 | return 66 | } 67 | user := SmtpUser{ 68 | Login: login, 69 | Passwd: string(hashed[:]), 70 | AuthRelay: authRelay, 71 | } 72 | 73 | return DB.Save(&user).Error 74 | } 75 | 76 | // DelUser delete an user 77 | func DelUser(login string) error { 78 | var err error 79 | // users exits ? 80 | var count int 81 | if err = DB.Model(SmtpUser{}).Where("login = ?", login).Count(&count).Error; err != nil { 82 | return err 83 | } 84 | if count == 0 { 85 | return errors.New("User " + login + " doesn't exists") 86 | } 87 | return DB.Where("login = ?", login).Delete(&SmtpUser{}).Error 88 | } 89 | 90 | // GetAuthorizedUsers returns users who can use SMTP to send mail 91 | func GetAllowedUsers() (users []SmtpUser, err error) { 92 | users = []SmtpUser{} 93 | err = DB.Where("auth_relay=?", true).Find(&users).Error 94 | return 95 | } 96 | */ 97 | -------------------------------------------------------------------------------- /core/smtproutes.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | // TODO remove 4 | 5 | /* 6 | type routemap struct { 7 | Host string 8 | Route string 9 | } 10 | 11 | type smtproute struct { 12 | Name string 13 | LocalAddrs string 14 | remoteAddrs string 15 | }*/ 16 | -------------------------------------------------------------------------------- /core/store.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | ) 7 | 8 | // Storer is a interface for stores 9 | type Storer interface { 10 | //TODO should return perm or temp failure 11 | Get(key string) (io.Reader, error) 12 | Put(key string, reader io.Reader) error 13 | Del(key string) error 14 | } 15 | 16 | // NewStore return a new srore 17 | func NewStore(driver, source string) (Storer, error) { 18 | switch driver { 19 | case "disk": 20 | return NewDiskStore() 21 | case "openstack": 22 | return newOpenstackStore() 23 | default: 24 | return nil, errors.New("no such driver " + driver + " for store") 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /core/store_disk.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "io" 7 | "io/ioutil" 8 | "os" 9 | "path" 10 | ) 11 | 12 | // DiskStore represents a physical disk store 13 | type diskStore struct { 14 | basePath string 15 | } 16 | 17 | // NewDiskStore returns a store with local disk as backend 18 | func NewDiskStore() (*diskStore, error) { 19 | basePath := path.Clean(Cfg.GetStoreSource()) 20 | // check if path exists & is writable 21 | fi, err := os.Stat(basePath) 22 | if err != nil { 23 | return nil, err 24 | } 25 | if !fi.IsDir() { 26 | return nil, errors.New(basePath + " is not a directory.") 27 | } 28 | f, err := os.OpenFile(path.Join(basePath, "testingIfIsWritable"), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0777) 29 | defer f.Close() 30 | if err != nil { 31 | return nil, err 32 | } 33 | os.Remove(path.Join(basePath, "testingIfIsWritable")) 34 | return &diskStore{basePath}, nil 35 | } 36 | 37 | // Get returns io.Reader corresponding to key 38 | func (s *diskStore) Get(key string) (io.Reader, error) { 39 | if key == "" { 40 | return nil, errors.New("diskStore.Get: key is empty") 41 | } 42 | spath := s.getStoragePath(key) 43 | raw, err := ioutil.ReadFile(spath) 44 | if err != nil { 45 | return nil, errors.New("diskStore.Get: unable to open " + spath + " for reading." + err.Error()) 46 | } 47 | return io.Reader(bytes.NewReader(raw)), nil 48 | } 49 | 50 | // Put save key value in store 51 | func (s *diskStore) Put(key string, reader io.Reader) error { 52 | var err error 53 | if key == "" { 54 | return errors.New("diskStore.Put: key is empty") 55 | } 56 | spath := s.getStoragePath(key) 57 | 58 | // Is file exist (should never happen but...) 59 | _, err = os.Stat(spath) 60 | if err != nil && !os.IsNotExist(err) { 61 | return err 62 | } 63 | // create path 64 | if err = os.MkdirAll(path.Dir(spath), 0766); err != nil { 65 | return err 66 | } 67 | f, err := os.OpenFile(spath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0700) 68 | if err != nil { 69 | return err 70 | } 71 | _, err = io.Copy(f, reader) 72 | return err 73 | } 74 | 75 | // Del 76 | func (s *diskStore) Del(key string) error { 77 | if key == "" { 78 | return errors.New("diskStore.Put: key is empty") 79 | } 80 | return os.Remove(s.getStoragePath(key)) 81 | } 82 | 83 | // getStoragePath returns storage path associated with key key 84 | func (s *diskStore) getStoragePath(key string) string { 85 | lenKey := len(key) 86 | if lenKey == 1 { 87 | return path.Join(s.basePath, key) 88 | } 89 | sPath := s.basePath 90 | for i := 0; i < lenKey-1; i++ { 91 | sPath = path.Join(sPath, key[i:i+1]) 92 | if i == 3 { 93 | break 94 | } 95 | } 96 | return path.Join(sPath, key) 97 | } 98 | -------------------------------------------------------------------------------- /core/store_openstack.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "strings" 7 | 8 | "github.com/toorop/gopenstack/objectstorage/v1" 9 | ) 10 | 11 | // DiskStore represents a physical disk store 12 | type openstackStore struct { 13 | Region string 14 | Container string 15 | } 16 | 17 | // newOpenstackStore check object storage and return a new openstackStore 18 | func newOpenstackStore() (*openstackStore, error) { 19 | osPath := objectstorageV1.NewOsPathFromPath(Cfg.GetStoreSource()) 20 | if !osPath.IsContainer() { 21 | return nil, errors.New("path " + Cfg.GetStoreDriver() + " is not a path to a valid openstack container") 22 | } 23 | // container exists ? 24 | container := &objectstorageV1.Container{ 25 | Region: osPath.Region, 26 | Name: osPath.Container, 27 | } 28 | err := container.Put(&objectstorageV1.ContainerRequestParameters{ 29 | IfNoneMatch: true, 30 | }) 31 | if err != nil { 32 | return nil, err 33 | } 34 | store := &openstackStore{ 35 | Region: osPath.Region, 36 | Container: osPath.Container, 37 | } 38 | return store, nil 39 | } 40 | 41 | // Put save key value in store 42 | func (s *openstackStore) Put(key string, reader io.Reader) error { 43 | if key == "" { 44 | return errors.New("store.Put: key is empty") 45 | } 46 | object := objectstorageV1.Object{ 47 | Name: key, 48 | Region: s.Region, 49 | Container: s.Container, 50 | RawData: reader, 51 | } 52 | return object.Put(&objectstorageV1.ObjectRequestParameters{ 53 | IfNoneMatch: true, 54 | }) 55 | } 56 | 57 | // Get returns io.Reader corresponding to key 58 | func (s *openstackStore) Get(key string) (io.Reader, error) { 59 | if key == "" { 60 | return nil, errors.New("store.Get: key is empty") 61 | } 62 | object := objectstorageV1.Object{ 63 | Name: key, 64 | Region: s.Region, 65 | Container: s.Container, 66 | } 67 | err := object.Get(nil) 68 | if err != nil { 69 | return nil, err 70 | } 71 | return object.RawData, nil 72 | } 73 | 74 | // Del 75 | func (s *openstackStore) Del(key string) error { 76 | if key == "" { 77 | return errors.New("store.Del: key is empty") 78 | } 79 | object := objectstorageV1.Object{ 80 | Name: key, 81 | Region: s.Region, 82 | Container: s.Container, 83 | } 84 | err := object.Delete(false) 85 | if err != nil && strings.HasPrefix(err.Error(), "404") { 86 | err = nil 87 | } 88 | return err 89 | } 90 | -------------------------------------------------------------------------------- /core/tls.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | // 4 | func tlsGetVersion(v uint16) string { 5 | versionMap := map[uint16]string{ 6 | 0x0300: "SSL 3.0", 7 | 0x0301: "TLS 1.0", 8 | 0x0302: "TLS 1.1", 9 | 0x0303: "TLS 1.2", 10 | } 11 | version, found := versionMap[v] 12 | if found { 13 | return version 14 | } 15 | return "unknow" 16 | } 17 | 18 | // tlsGetCipherSuite returns cipher suite as string 19 | func tlsGetCipherSuite(cs uint16) string { 20 | csMap := map[uint16]string{ 21 | 0x0005: "TLS_RSA_WITH_RC4_128_SHA", 22 | 0x000a: "TLS_RSA_WITH_3DES_EDE_CBC_SHA", 23 | 0x002f: "TLS_RSA_WITH_AES_128_CBC_SHA", 24 | 0x0035: "TLS_RSA_WITH_AES_256_CBC_SHA", 25 | 0xc007: "TLS_ECDHE_ECDSA_WITH_RC4_128_SHA", 26 | 0xc009: "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA", 27 | 0xc00a: "TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA", 28 | 0xc011: "TLS_ECDHE_RSA_WITH_RC4_128_SHA", 29 | 0xc012: "TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA", 30 | 0xc013: "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA", 31 | 0xc014: "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA", 32 | 0xc02f: "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", 33 | 0xc02b: "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", 34 | 0xc030: "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", 35 | 0xc02c: "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384", 36 | // TLS_FALLBACK_SCSV isn't a standard cipher suite but an indicator 37 | // that the client is doing version fallback. See 38 | // https://tools.ietf.org/html/draft-ietf-tls-downgrade-scsv-00. 39 | 0x5600: "TLS_FALLBACK_SCSV", 40 | } 41 | cipher, found := csMap[cs] 42 | if found { 43 | return cipher 44 | } 45 | return "unknow" 46 | } 47 | -------------------------------------------------------------------------------- /core/user.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "errors" 5 | "net/mail" 6 | "strings" 7 | 8 | "github.com/jinzhu/gorm" 9 | "github.com/tredoe/osutil/user/crypt/sha512_crypt" 10 | "golang.org/x/crypto/bcrypt" 11 | ) 12 | 13 | // User represents a tmail user. 14 | type User struct { 15 | Id int64 16 | Login string `sql:"unique"` 17 | Passwd string `sql:"not null"` 18 | DovePasswd string `sql:"null"` // SHA512 passwd workaround (glibc on most linux flavor doesn't have bcrypt support) 19 | Active string `sql:"type:char(1);default:'Y'"` //rune `sql:"type:char(1);not null;default:'Y'` 20 | AuthRelay bool `sql:"default:false"` // authorization of relaying 21 | HaveMailbox bool `sql:"default:false"` 22 | IsCatchall bool `sql:"default:false"` 23 | MailboxQuota string `sql:"null"` 24 | Home string `sql:"null"` // used by dovecot to store mailbox 25 | } 26 | 27 | // UserAdd add an user 28 | func UserAdd(login, passwd, mbQuota string, haveMailbox, authRelay, isCatchall bool) error { 29 | login = strings.ToLower(login) 30 | // login must be < 257 char 31 | l := len(login) 32 | if l > 256 { 33 | return errors.New("login must have less than 256 chars") 34 | } 35 | if l < 4 { 36 | return errors.New("login must be at least 4 char") 37 | } 38 | 39 | // passwd > 6 char 40 | if len(passwd) < 6 { 41 | return errors.New("password must be at least 6 chars length") 42 | } 43 | 44 | // no catchall without mailbox 45 | if isCatchall && !haveMailbox { 46 | return errors.New("only users with mailbox can be defined as catchall") 47 | } 48 | 49 | //OK 50 | user := &User{ 51 | Login: login, 52 | AuthRelay: authRelay, 53 | HaveMailbox: haveMailbox, 54 | IsCatchall: isCatchall, 55 | } 56 | 57 | // if we have to create mailbox, login must be a valid email address 58 | if haveMailbox { 59 | // check if dovecot is available 60 | if !Cfg.GetDovecotSupportEnabled() { 61 | return errors.New("you must enable (and install) Dovecot support") 62 | } 63 | 64 | if _, err := mail.ParseAddress(login); err != nil { 65 | return errors.New("'login' must be a valid email address") 66 | } 67 | 68 | t := strings.Split(login, "@") 69 | if len(t) != 2 { 70 | return errors.New("'login' must be a valid email address") 71 | } 72 | 73 | // Quota 74 | if mbQuota == "" { 75 | // get default 76 | mbQuota = Cfg.GetUserMailboxDefaultQuota() 77 | } 78 | user.MailboxQuota = mbQuota 79 | 80 | // rcpthost must be in rcpthost && must be local && not an alias 81 | rcpthost, err := RcpthostGet(t[1]) 82 | if err != nil && err != gorm.ErrRecordNotFound { 83 | return err 84 | } 85 | exists := err == nil 86 | if !exists { 87 | err = DB.Save(&RcptHost{ 88 | Hostname: t[1], 89 | IsLocal: true, 90 | }).Error 91 | if err != nil { 92 | return err 93 | } 94 | } else if !rcpthost.IsLocal { 95 | return errors.New("rcpthost " + t[1] + " is already handled by tmail but declared as remote destination") 96 | } else if rcpthost.IsAlias { 97 | return errors.New("rcpthost " + t[1] + " is an domain alias. You can't add user for this kind of domain") 98 | } 99 | // home = base/d/domain/u/user 100 | user.Home = Cfg.GetUsersHomeBase() + "/" + string(t[1][0]) + "/" + t[1] + "/" + string(t[0][0]) + "/" + t[0] 101 | 102 | // catchall 103 | if isCatchall { 104 | // is there another catchall for this domain 105 | u, err := UserGetCatchallForDomain(t[1]) 106 | if err != nil { 107 | if err != gorm.ErrRecordNotFound { 108 | return errors.New("unable to check catchall existense for domain " + t[1] + ": " + err.Error()) 109 | } 110 | u = nil 111 | } 112 | if u != nil { 113 | return errors.New("domain " + t[1] + " already have a catchall: " + u.Login) 114 | } 115 | user.IsCatchall = true 116 | } 117 | } 118 | 119 | // hash passwd 120 | hashed, err := bcrypt.GenerateFromPassword([]byte(passwd), 10) 121 | if err != nil { 122 | return err 123 | } 124 | user.Passwd = string(hashed) 125 | 126 | // sha512 for dovecot compatibility 127 | // {SHA512-CRYPT}$6$iW6KmxlZL56A1raN$4DjgXTUzFZlGQgq61YnBMF2AYWKdY5ZanOUWTDBhuvBYVzkdNjqrmpYnLlQ3M0kU1joUH0Bb2aJcPhUF0xlSq/ 128 | salt, err := NewUUID() 129 | if err != nil { 130 | return err 131 | } 132 | salt = "$6$" + salt[:16] 133 | c := sha512_crypt.New() 134 | user.DovePasswd, err = c.Generate([]byte(passwd), []byte(salt)) 135 | if err != nil { 136 | return err 137 | } 138 | return DB.Save(user).Error 139 | } 140 | 141 | // UserGet return an user by is login/passwd 142 | func UserGet(login, passwd string) (user *User, err error) { 143 | user = &User{} 144 | // check input 145 | if len(login) == 0 || len(passwd) == 0 { 146 | err := errors.New("login or passwd is empty") 147 | return nil, err 148 | } 149 | 150 | err = DB.Where("login = ?", login).Find(user).Error 151 | if err != nil { 152 | return nil, err 153 | } 154 | // Encoding passwd 155 | //hashed, err := bcrypt.GenerateFromPassword([]byte(passwd), 10) 156 | //log.Println(string(hashed), err) 157 | 158 | // Check passwd 159 | err = bcrypt.CompareHashAndPassword([]byte(user.Passwd), []byte(passwd)) 160 | return 161 | } 162 | 163 | // UserGetByLogin return an user from his login 164 | func UserGetByLogin(login string) (user *User, err error) { 165 | user = &User{} 166 | err = DB.Where("login = ?", strings.ToLower(login)).Find(user).Error 167 | return 168 | } 169 | 170 | // UserGetCatchallForDomain return catchall 171 | func UserGetCatchallForDomain(domain string) (user *User, err error) { 172 | user = &User{} 173 | err = DB.Where("login LIKE ? AND is_catchall=?", "%"+strings.ToLower(domain), true).Find(user).Error 174 | return 175 | } 176 | 177 | // UserList return all user 178 | func UserList() (users []User, err error) { 179 | users = []User{} 180 | err = DB.Find(&users).Error 181 | return 182 | } 183 | 184 | // UserDel delete an user 185 | func UserDel(login string) error { 186 | exists, err := UserExists(login) 187 | if err != nil { 188 | return err 189 | } 190 | if !exists { 191 | return errors.New("User " + login + " doesn't exists") 192 | } 193 | // TODO on doit verifier si l'host doit etre supprimé de rcpthost 194 | return DB.Where("login = ?", login).Delete(&User{}).Error 195 | } 196 | 197 | // UserExists checks if an user exists 198 | func UserExists(login string) (bool, error) { 199 | err := DB.Where("login=?", strings.ToLower(login)).Find(&User{}).Error 200 | if err == nil { 201 | return true, nil 202 | } 203 | if err != gorm.ErrRecordNotFound { 204 | return false, err 205 | } 206 | return false, nil 207 | } 208 | 209 | // UserChangePassword is used to change user password 210 | func UserChangePassword(login, password string) error { 211 | user, err := UserGetByLogin(login) 212 | if err != nil { 213 | return err 214 | } 215 | return user.ChangePasswd(password) 216 | } 217 | 218 | // ChangePasswd is used to change user password 219 | func (u *User) ChangePasswd(passwd string) error { 220 | if len(passwd) < 6 { 221 | return errors.New("password must be at least 6 chars length") 222 | } 223 | hashed, err := bcrypt.GenerateFromPassword([]byte(passwd), 10) 224 | if err != nil { 225 | return err 226 | } 227 | u.Passwd = string(hashed) 228 | if u.HaveMailbox { 229 | salt, err := NewUUID() 230 | if err != nil { 231 | return err 232 | } 233 | salt = "$6$" + salt[:16] 234 | c := sha512_crypt.New() 235 | if u.DovePasswd, err = c.Generate([]byte(passwd), []byte(salt)); err != nil { 236 | return err 237 | } 238 | } 239 | return DB.Save(u).Error 240 | } 241 | -------------------------------------------------------------------------------- /core/utils.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "bytes" 5 | "io/ioutil" 6 | "net" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | ) 11 | 12 | // GetDistPath returns basePath (where tmail binaries is) 13 | func GetBasePath() string { 14 | p, _ := filepath.Abs(filepath.Dir(os.Args[0])) 15 | return p 16 | } 17 | 18 | // RemoveBrackets removes trailing and ending brackets ( -> string) 19 | func RemoveBrackets(s string) string { 20 | if strings.HasPrefix(s, "<") { 21 | s = s[1:] 22 | } 23 | if strings.HasSuffix(s, ">") { 24 | s = s[0 : len(s)-1] 25 | } 26 | return s 27 | } 28 | 29 | // Check if a string is in a Slice of string 30 | // TODO: replace by sort package 31 | func IsStringInSlice(str string, s []string) (found bool) { 32 | found = false 33 | for _, t := range s { 34 | if t == str { 35 | found = true 36 | break 37 | } 38 | } 39 | return 40 | } 41 | 42 | // StripQuotes remove trailing and ending " 43 | func StripQuotes(s string) string { 44 | if s == "" { 45 | return s 46 | } 47 | if s[0] == '"' && s[len(s)-1] == '"' { 48 | return s[1 : len(s)-1] 49 | } 50 | return s 51 | } 52 | 53 | // IsIPV4 return true if ip is ipV4 54 | // todo: refactor 55 | func IsIPV4(ip string) bool { 56 | if len(ip) > 15 { 57 | return false 58 | } 59 | return true 60 | } 61 | 62 | // Unix2dos replace all line ending from \n to \r\n 63 | func Unix2dos(ch *[]byte) (err error) { 64 | dos := bytes.NewBuffer([]byte{}) 65 | var prev byte 66 | prev = 0 67 | for _, b := range *ch { 68 | if b == 10 && prev != 13 { 69 | if _, err = dos.Write([]byte{13, 10}); err != nil { 70 | return 71 | } 72 | 73 | } else { 74 | if err = dos.WriteByte(b); err != nil { 75 | return 76 | } 77 | } 78 | prev = b 79 | } 80 | *ch, err = ioutil.ReadAll(dos) 81 | return nil 82 | } 83 | 84 | // isFQN checks if domain is FQN (MX or A record) 85 | func isFQN(host string) (bool, error) { 86 | _, err := net.LookupMX(host) 87 | if err != nil { 88 | if strings.HasSuffix(err.Error(), "no such host") { 89 | // Try A 90 | _, err = net.LookupHost(host) 91 | if err != nil { 92 | if strings.HasSuffix(err.Error(), "no such host") { 93 | return false, nil 94 | } 95 | return false, err 96 | } 97 | } 98 | } 99 | return true, nil 100 | } 101 | -------------------------------------------------------------------------------- /core/uuid.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "crypto/rand" 5 | "crypto/sha1" 6 | "fmt" 7 | "io" 8 | ) 9 | 10 | // newUUID generates a random UUID according to RFC 4122 11 | func NewUUID() (string, error) { 12 | uuid := make([]byte, 16) 13 | n, err := io.ReadFull(rand.Reader, uuid) 14 | if n != len(uuid) || err != nil { 15 | return "", err 16 | } 17 | hasher := sha1.New() 18 | hasher.Write(uuid) 19 | return fmt.Sprintf("%x", hasher.Sum(nil)), nil 20 | } 21 | -------------------------------------------------------------------------------- /dist/conf/tmail.cfg.base: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | 4 | ### 5 | # Common 6 | 7 | # Who am i (used in SMTP transaction for HELO) 8 | export TMAIL_ME="tmail.io" 9 | 10 | # Server signature 11 | export TMAIL_HIDE_SERVER_SIGNATURE=false 12 | 13 | # debug 14 | export TMAIL_DEBUG_ENABLED=false 15 | 16 | # run tmail as cluster 17 | # default false 18 | export TMAIL_CLUSTER_MODE_ENABLED=false 19 | 20 | # Temporary directory (for scanning/filtering) 21 | # RAMDISK recommended 22 | export TMAIL_TEMPDIR="/dev/shm" 23 | 24 | # Where to log 25 | # "stdout" for logging too stdout otherwise set a path to an *existing* directory 26 | export TMAIL_LOGPATH="stdout" 27 | 28 | ### 29 | # nsqd 30 | 31 | # dis|enable logging 32 | export TMAIL_NSQD_ENABLE_LOGGIN=false 33 | 34 | # lookupd-tcp-address 35 | # Format "IP1:PORT1;IP2:PORT2" 36 | export TMAIL_NSQ_LOOKUPD_TCP_ADDRESSES="127.0.0.1:4160" 37 | export TMAIL_NSQ_LOOKUPD_HTTP_ADDRESSES="127.0.0.1:4161" 38 | 39 | ### 40 | # Database 41 | # 42 | # tmail currenlty support: 43 | # sqlite3 44 | # MySQL (and compatibles DB like percona, mariaDB) 45 | # PostgreSQL 46 | 47 | # Database driver & source 48 | # 49 | # Exemple 50 | # "postgres" "user=gorm dbname=gorm sslmode=disable" 51 | ## Mysql tcp 52 | # export TMAIL_DB_SOURCE="user:passwd@tcp(ip:port)/tmail?parseTime=true" 53 | # export TMAIL_DB_DRIVER="mysql" 54 | ## Mysql socket 55 | # export TMAIL_DB_SOURCE="user:passwd@unix(/path/to/socket)/tmail?parseTime=true" 56 | # export TMAIL_DB_DRIVER="mysql" 57 | ## sqlite 58 | # "sqlite3" "/tmp/gorm.db" 59 | export TMAIL_DB_DRIVER="sqlite3" 60 | export TMAIL_DB_SOURCE="/home/tmail/dist/db/tmail.db?_busy_timeout=60000" 61 | 62 | # Bolt DB (wher to store the botl DB file) 63 | export TMAIL_BOLT_FILE="/home/tmail/dist/db/bolt.db" 64 | 65 | 66 | ## 67 | # Store 68 | # 69 | # Drivers supported 70 | # disk: source is baspath 71 | # 72 | export TMAIL_STORE_DRIVER="disk" 73 | export TMAIL_STORE_SOURCE="/home/tmail/dist/store" 74 | # For openstack 75 | # export TMAIL_STORE_DRIVER="openstack" 76 | # export TMAIL_STORE_SOURCE="/SBG1/tmail" 77 | 78 | 79 | 80 | ### 81 | # smtpd 82 | 83 | # launch smtpd ? (default false) 84 | export TMAIL_SMTPD_LAUNCH=true; 85 | 86 | 87 | # Defines dnsS for smtpd to launch 88 | # A dns is in the form 89 | # IP:PORT:SSL 90 | # IP: ip address to listen to 91 | # PORT: associated port 92 | # SSL: activate SSL 93 | # if SSL is true all transactions will be encrypted 94 | # if SSL is false transactions will be clear by default but they will be upgraded 95 | # via STARTTLS smtp extension/cmd 96 | # 97 | # Exemple: 98 | # "127.0.0.1:2525:false;127.0.0.1:4656:true" 99 | # will launch 2 smtpd deamons 100 | # - one listening on 127.0.0.1:2525 without encryption (but upgradable via STARTTLS) 101 | # - one listening on 127.0.0.1:4656 with encryption 102 | export TMAIL_SMTPD_DSNS="0.0.0.0:2525:false" 103 | 104 | # smtp server timeout in seconds 105 | # throw a timeout if smtp client does not show signs of life 106 | # after this delay 107 | # Default 300 (RFC 5321 4.5.3.2.7) 108 | export TMAIL_SMTPD_SERVER_TIMEOUT=60 109 | 110 | # Max bytes for the data cmd (max size of incoming mail) 111 | # Default 0 unlimited 112 | export TMAIL_SMTPD_MAX_DATABYTES=50000000 113 | 114 | # Number of relays who previously take mail in charge 115 | # -> preventing loops 116 | # default 30 117 | export TMAIL_SMTPD_MAX_HOPS=50 118 | 119 | # Maximum of RCPT TO per transaction 120 | # when is reached serveur will reply with a 451 error (4.1.0) 121 | # to be full RFC compliant it should be 0 122 | export TMAIL_SMTP_MAX_RCPT=0 123 | 124 | # Drop smtp session after TMAIL_SMTP_MAX_BAD_RCPT unavailable RCPT TO 125 | # to be full RFC compliant it should be 0 126 | export TMAIL_SMTP_MAX_BAD_RCPT=0 127 | 128 | # Number of simultaneous incoming SMTP sessions 129 | # Default 20 130 | export TMAIL_SMTPD_CONCURRENCY_INCOMING=20 131 | 132 | ### Filters 133 | # Clamav 134 | export TMAIL_SMTPD_SCAN_CLAMAV_ENABLED=false 135 | 136 | # Clamd DSNS 137 | # name:ip:port 138 | # name:socket 139 | export TMAIL_SMTPD_SCAN_CLAMAV_DSNS="/var/run/clamav/clamd.ctl" 140 | 141 | 142 | ### 143 | # deliverd 144 | 145 | # Launch deliverd ? 146 | export TMAIL_DELIVERD_LAUNCH=true 147 | 148 | # Locals addresses 149 | # 150 | # Formating : 151 | # ip1SEPip2SEPip3SEP... 152 | # 153 | # Separator could be : 154 | # | -> or -> round robin 155 | # & -> and -> fail over 156 | # Warning: you can't mix | and & 157 | 158 | # Examples : 159 | # 127.0.0.1&127.0.0.2&127.0.0.3 160 | # deliverd will start tring with 127.0.0.1, if it doesn't works it will try with 127.0.0.2 ... 161 | # 162 | # 127.0.0.1|127.0.0.2|127.0.0.3|127.0.0.3 163 | # deliverd will use local IP in a random order 164 | # If an IP is present X time this will increase its priority 165 | # 166 | # You must define at least one local addresse 167 | export TMAIL_DELIVERD_LOCAL_IPS="0.0.0.0" 168 | 169 | # Local Concurrency 170 | export TMAIL_DELIVERD_LOCAL_CONCURRENCY=50 171 | 172 | # Remote Concurrency 173 | export TMAIL_DELIVERD_REMOTE_CONCURRENCY=50 174 | 175 | # SMTP client timeout per command 176 | export TMAIL_DELIVERD_REMOTE_TIMEOUT=300 177 | 178 | # Default queue lifetime in minutes 179 | # After this delay 180 | # Bounce on temp failure 181 | # discard if bounce failed 182 | export TMAIL_DELIVERD_QUEUE_LIFETIME=60400 183 | 184 | # Specific queue lidetime for bounces 185 | export TMAIL_DELIVERD_QUEUE_BOUNCES_LIFETIME=10080 186 | 187 | # TMAIL_DELIVERD_REMOTE_TLS_SKIPVERIFY controls whether a client verifies the 188 | # server's certificate chain and host name. 189 | # If TMAIL_DELIVERD_REMOTE_TLS_SKIPVERIFY is true, TLS accepts any certificate 190 | # presented by the server and any host name in that certificate. 191 | # In this mode, TLS is susceptible to man-in-the-middle attacks. 192 | # Unfortunatly a lot of SMTP server have selfs signed certs so if you use tmail 193 | # for sending mail you should set this value to true 194 | export TMAIL_DELIVERD_REMOTE_TLS_SKIPVERIFY=true 195 | 196 | # Fallback (downgrade) to clear transaction if STARTTLS negociation failed 197 | # default: false 198 | export TMAIL_DELIVERD_REMOTE_TLS_FALLBACK=true 199 | 200 | 201 | # DKIM sign outgoing (remote) emails 202 | export TMAIL_DELIVERD_DKIM_SIGN=false 203 | 204 | ## 205 | # RFC compliance 206 | 207 | # RFC 5321 4.1.1.1 a client SHOULD start an SMTP session with the EHLO 208 | # command 209 | # default false 210 | export TMAIL_RFC_HELO_MANDATORY=false 211 | 212 | # RFC 5321 2.3.5: the domain name given MUST be either a primary hostname 213 | # (resovable) or an address 214 | # default: true (warning a lot of SMTP clients do not send a fqn|address ) 215 | export TMAIL_RFC_HELO_NEED_FQN=false 216 | 217 | 218 | # RFC 5321 4.5.3.1.1: The maximum total length of a user name or 219 | # other local-part is 64 octets. 220 | export TMAIL_RFC_MAILFROM_LOCALPART_SIZE=true 221 | 222 | 223 | ## 224 | # users 225 | 226 | # Base path for users "home". Currently ysed to store mailboxes 227 | export TMAIL_USERS_HOME_BASE="/home/tmail/dist/mailboxes" 228 | 229 | # Default quota for user mailboxes in bytes (not bit) 230 | # eg: 1G, 100M, 100K, 10000000 231 | export TMAIL_USERS_MAILBOX_DEFAULT_QUOTA="200M" 232 | 233 | ## 234 | # HTTP REST server 235 | 236 | # Launch REST server 237 | export TMAIL_REST_SERVER_LAUNCH=false 238 | 239 | # REST server IP 240 | export TMAIL_REST_SERVER_IP="127.0.0.1" 241 | 242 | # REST server port 243 | export TMAIL_REST_SERVER_PORT=8080 244 | 245 | # REST server is TLS (https) ? 246 | export TMAIL_REST_SERVER_IS_TLS=false 247 | 248 | # Login for HTTP auth 249 | export TMAIL_REST_SERVER_LOGIN="login" 250 | 251 | # Passwd for HTTP auth 252 | export TMAIL_REST_SERVER_PASSWD="passwd" 253 | 254 | ## 255 | # Microservices 256 | 257 | # Called on new SMTP connection from client 258 | export TMAIL_MS_SMTPD_NEWCLIENT="" 259 | 260 | # Called after HELO/EHLO command 261 | export TMAIL_MS_SMTPD_HELO="" 262 | 263 | # Called after MAIL FROM command 264 | export TMAIL_MS_SMTPD_MAIL_FROM="" 265 | 266 | # Called after RCPT TO to check if relay is granted for this RCPT TO 267 | export TMAIL_MS_SMTPD_RCPTTO="" 268 | 269 | # Call after DATA command 270 | export TMAIL_MS_SMTPD_DATA="" 271 | 272 | # smtpd before queueing: used to change envelope 273 | export TMAIL_MS_SMTPD_BEFORE_QUEUEING="" 274 | 275 | #smtpd telemetry 276 | export TMAIL_MS_SMTPD_SEND_TELEMETRY="" 277 | 278 | # Remote routes for deliverd 279 | export TMAIL_MS_DELIVERD_GET_ROUTES="" 280 | 281 | # deliverd telemetry 282 | export TMAIL_MS_DELIVERD_SEND_TELEMETRY="" 283 | 284 | ## 285 | # Openstack 286 | # paste your rcfile here 287 | export TMAIL_OPENSTACK_ENABLE=false 288 | 289 | # Auth url 290 | export OS_AUTH_URL=https://auth.cloud.ovh.net/v2.0 291 | 292 | # With the addition of Keystone we have standardized on the term **tenant** 293 | # as the entity that owns the resources. 294 | export OS_TENANT_ID=tenant 295 | export OS_TENANT_NAME="name" 296 | 297 | # In addition to the owning entity (tenant), openstack stores the entity 298 | # performing the action as the **user**. 299 | export OS_USERNAME="username" 300 | 301 | # With Keystone you pass the keystone password. 302 | #echo "Please enter your OpenStack Password: " 303 | #read -sr OS_PASSWORD_INPUT 304 | export OS_PASSWORD="passwd" 305 | 306 | # If your configuration has multiple regions, we set that information here. 307 | # OS_REGION_NAME is optional and only valid in certain environments. 308 | export OS_REGION_NAME="GRA1" 309 | 310 | 311 | ## 312 | # Dovecot 313 | 314 | # Enabled dovecot for local deliveries 315 | export TMAIL_DOVECOT_SUPPORT_ENABLED=false 316 | 317 | # Dovecot LDA path 318 | export TMAIL_DOVECOT_LDA="/usr/lib/dovecot/dovecot-lda" 319 | 320 | ## 321 | # plugin 322 | # Export here env var need for your plugins 323 | 324 | -------------------------------------------------------------------------------- /dist/run: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ "$(uname -m)" = "x86_64" ]; 4 | then 5 | . conf/tmail.cfg && ./tmail 6 | else 7 | . conf/tmail.cfg && ./tmail32 8 | fi 9 | -------------------------------------------------------------------------------- /dist/ssl/server.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDbjCCAlYCCQCZX/yCapaq3jANBgkqhkiG9w0BAQsFADB5MQswCQYDVQQGEwJG 3 | UjENMAsGA1UECAwEVGFybjENMAsGA1UEBwwEQWxiaTETMBEGA1UECgwKVG9vcm9w 4 | IExhYjEWMBQGA1UEAwwNc210cC50bWFpbC5pbzEfMB0GCSqGSIb3DQEJARYQdG9v 5 | cm9wQHRvb3JvcC5mcjAeFw0xNDEyMjkxMTAyNDJaFw0xNTEyMjkxMTAyNDJaMHkx 6 | CzAJBgNVBAYTAkZSMQ0wCwYDVQQIDARUYXJuMQ0wCwYDVQQHDARBbGJpMRMwEQYD 7 | VQQKDApUb29yb3AgTGFiMRYwFAYDVQQDDA1zbXRwLnRtYWlsLmlvMR8wHQYJKoZI 8 | hvcNAQkBFhB0b29yb3BAdG9vcm9wLmZyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A 9 | MIIBCgKCAQEAzRNTQTnSnnoVCVr8wdWjY7iGLEMg8esYHTF6EMdKtiw3/vcLPciJ 10 | 94cYxQzmPxXv1i84EWGOVClzKv95dwW9wrV4U+sH7tJhl+lgtgAdDJA9ev1r3fV2 11 | JaQWZGKWuYSZzXY4cwIn+hZorD28x1ZdEqQGiDmPYv5XBrjOFuLFuXAqelxU86MC 12 | HyfdRJ5T/XwUcpTPmui/vTV1PTyHefhV3hSuwiFJihsvxtt7Qh1zSb6OA0LYr5Vt 13 | P/spQb8zJ5+dTyL8yf/ORZFtJ5JABFYagEN+zrYYFwTQhf/6h/aMlHuWUv5OAW1Q 14 | aRASDDw/cZAPKDa4vofqhQ1mhCFKWU4bnwIDAQABMA0GCSqGSIb3DQEBCwUAA4IB 15 | AQBvHrUHfO8AJ+wjqV4xB2ILves00X3yu/wZPTey/adkv0Q/SFFcdIi/pL7Mv31F 16 | twxPWWFjLyVfrAlq2mXjAbpqcRseBTNOMymKGVtEh8BB+67Bujk6DxWoDzWdHkaA 17 | iCHgP9FPe0klyMBxUkzNaDMlgvnstCE0BeZWqxvtCvg0d3mt2XPvU5dW6kA0CCFd 18 | WuHtQFmfjipQYYbVYauRpzinPmA6YuF8/cCEY2sC2UDFECf9WpVNPniLZEETTnLs 19 | 2J/LU8BjQzz8m+7FgPFeHrLj5yFZUkN6Dja3qeKto7O6oZcBZvexifbFx0tjULTA 20 | xmSAxLmGcrAn+V/7+w4aRlfo 21 | -----END CERTIFICATE----- 22 | -------------------------------------------------------------------------------- /dist/ssl/server.csr: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE REQUEST----- 2 | MIICvjCCAaYCAQAweTELMAkGA1UEBhMCRlIxDTALBgNVBAgMBFRhcm4xDTALBgNV 3 | BAcMBEFsYmkxEzARBgNVBAoMClRvb3JvcCBMYWIxFjAUBgNVBAMMDXNtdHAudG1h 4 | aWwuaW8xHzAdBgkqhkiG9w0BCQEWEHRvb3JvcEB0b29yb3AuZnIwggEiMA0GCSqG 5 | SIb3DQEBAQUAA4IBDwAwggEKAoIBAQDNE1NBOdKeehUJWvzB1aNjuIYsQyDx6xgd 6 | MXoQx0q2LDf+9ws9yIn3hxjFDOY/Fe/WLzgRYY5UKXMq/3l3Bb3CtXhT6wfu0mGX 7 | 6WC2AB0MkD16/Wvd9XYlpBZkYpa5hJnNdjhzAif6FmisPbzHVl0SpAaIOY9i/lcG 8 | uM4W4sW5cCp6XFTzowIfJ91EnlP9fBRylM+a6L+9NXU9PId5+FXeFK7CIUmKGy/G 9 | 23tCHXNJvo4DQtivlW0/+ylBvzMnn51PIvzJ/85FkW0nkkAEVhqAQ37OthgXBNCF 10 | //qH9oyUe5ZS/k4BbVBpEBIMPD9xkA8oNri+h+qFDWaEIUpZThufAgMBAAGgADAN 11 | BgkqhkiG9w0BAQsFAAOCAQEAQzI95ErXn4DClc/yfTCBLLj7xcjKNuAlJBiCn59f 12 | vWdZ3fvLDPi7OKFmt1OXZ9kjlMAispFC6yD3uH//XCIifR7yW63rUZHN4CTRtxQE 13 | 0JCPOttRtON9v113xC05FtK9vLQ5L9wam4DLekH44WBKLEDtARXvz1bd0iDU40eL 14 | yCKHuwVNGw3yRrXvzQ7SUwhyUIT31njTLVR/aBdw11h4HlcrbPkBRPAAI3wUIH7h 15 | YU/odUD3ZvcPoxhkuqAup8k5CqkqSGS2DHywfKyD6FmfnUt8HocvKPlh+RpXIhYN 16 | TXAvgpniZcrNH7jRUrezPYJKdrMnHdQ+dcJhpQmaikdGnA== 17 | -----END CERTIFICATE REQUEST----- 18 | -------------------------------------------------------------------------------- /dist/ssl/server.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEowIBAAKCAQEAzRNTQTnSnnoVCVr8wdWjY7iGLEMg8esYHTF6EMdKtiw3/vcL 3 | PciJ94cYxQzmPxXv1i84EWGOVClzKv95dwW9wrV4U+sH7tJhl+lgtgAdDJA9ev1r 4 | 3fV2JaQWZGKWuYSZzXY4cwIn+hZorD28x1ZdEqQGiDmPYv5XBrjOFuLFuXAqelxU 5 | 86MCHyfdRJ5T/XwUcpTPmui/vTV1PTyHefhV3hSuwiFJihsvxtt7Qh1zSb6OA0LY 6 | r5VtP/spQb8zJ5+dTyL8yf/ORZFtJ5JABFYagEN+zrYYFwTQhf/6h/aMlHuWUv5O 7 | AW1QaRASDDw/cZAPKDa4vofqhQ1mhCFKWU4bnwIDAQABAoIBAQCtMuMftWwyuDzI 8 | F/Zc5sgF0rRO8asDZmCJV14WiZqJ3TK1vYPa/GG5knnTAp/7K9XReTPLSi9g2VkR 9 | OY8mfMzVg1pK1bdvdnNCT7KEQ/hEwhWKqDnPzh2okLrwsWtG57zWEECAsZN93ist 10 | PT8Qw9n7gliZ+LMnElQBs1crcP85KwVLASjJvAxiAdoF1SOSea6cOW+nBbUXFyvK 11 | yWtKLASwH79W9AEQCDK36zzhFHRad6a7lHA15SKODaJqFbopA5qDlGMBeV7HEawz 12 | opEXIg4vBqrKJOdO2Q8PCV9H83tWWm7RMOb7CYoX2+pVOHO+7Y/243HmHLA0J4k3 13 | 5tXQkO5BAoGBAObEATJf9w/5owwb+Jo7J0UqMemC4PYfMces3lyyf7lU/WvFWcYT 14 | XLAHqKelFQmMCPBaSZs7qqaiomcVf/xeLqbJ+zMrEK/Ny9E9o9YFT9rrV852EQaL 15 | 4gO/DymhkkfrYfHil4mt5EdrRQKMX8yX5akcdrPcayaiBaRNPktq6FpvAoGBAOOA 16 | KCYWeak+yhLFp8NvxsbWddgFq/Nn9HBT4AC/gCo4dsEVUGE6hPMrr3XhIHm6/Wa8 17 | W017XdZaX8u2DADtuyIqnooNzjb8+RvBT1zAUjIlOGIhwWBy9qFpX2anrk0R3CtP 18 | ITH10/5ktg/H7Q686O0PY1175Qf28CCRvf8JJ6nRAoGAJEOrGHqCPe1yFQYURFCF 19 | dFYUL+kUZzkxvnpJG3Ilpj9X7+a8m+cRCsy5UVcc/joWcYcOyClRQQyPzvlO+p7m 20 | X+mf40OiRK5nmENCivCcwv929ggR1uCGrSYKQPWWIl04MCX2wHkmRZ7y4lqi92jr 21 | e27wrIU4BYMytcY5wupTB1sCgYASmlUuICJcq4y8kjsQqSA4/Cpwuq3/3l1HniQw 22 | C3jAexOC4GpNOQrME6NqYTlVmuvDrd1NbawTrhotPzqmDMqDlbaXFV/qcS8xjNIf 23 | hH50KUT+CUKVz3DJbCNn8og3NyGozPSq8C4gnD2i9rc0wE/PqrV2XH4y84dZMnG1 24 | 3BrJMQKBgHIDfE+ToCMv2vnyeRHGM+igsHvpI+bCtwjUg1UJkdc3w0mQ/At4W1B0 25 | uiajd3fa4cmpXBCF0JYXhpx6GpbhRyODupbeq14LkJ9274BT7oe3toeEd4z9ks3X 26 | 9xSHkQrL621wulItvQFlpZiHy4g3y6IxPU0vjSrfL92pFqw+xG+1 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /dist/ssl/web_server.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDbjCCAlYCCQCZX/yCapaq3jANBgkqhkiG9w0BAQsFADB5MQswCQYDVQQGEwJG 3 | UjENMAsGA1UECAwEVGFybjENMAsGA1UEBwwEQWxiaTETMBEGA1UECgwKVG9vcm9w 4 | IExhYjEWMBQGA1UEAwwNc210cC50bWFpbC5pbzEfMB0GCSqGSIb3DQEJARYQdG9v 5 | cm9wQHRvb3JvcC5mcjAeFw0xNDEyMjkxMTAyNDJaFw0xNTEyMjkxMTAyNDJaMHkx 6 | CzAJBgNVBAYTAkZSMQ0wCwYDVQQIDARUYXJuMQ0wCwYDVQQHDARBbGJpMRMwEQYD 7 | VQQKDApUb29yb3AgTGFiMRYwFAYDVQQDDA1zbXRwLnRtYWlsLmlvMR8wHQYJKoZI 8 | hvcNAQkBFhB0b29yb3BAdG9vcm9wLmZyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A 9 | MIIBCgKCAQEAzRNTQTnSnnoVCVr8wdWjY7iGLEMg8esYHTF6EMdKtiw3/vcLPciJ 10 | 94cYxQzmPxXv1i84EWGOVClzKv95dwW9wrV4U+sH7tJhl+lgtgAdDJA9ev1r3fV2 11 | JaQWZGKWuYSZzXY4cwIn+hZorD28x1ZdEqQGiDmPYv5XBrjOFuLFuXAqelxU86MC 12 | HyfdRJ5T/XwUcpTPmui/vTV1PTyHefhV3hSuwiFJihsvxtt7Qh1zSb6OA0LYr5Vt 13 | P/spQb8zJ5+dTyL8yf/ORZFtJ5JABFYagEN+zrYYFwTQhf/6h/aMlHuWUv5OAW1Q 14 | aRASDDw/cZAPKDa4vofqhQ1mhCFKWU4bnwIDAQABMA0GCSqGSIb3DQEBCwUAA4IB 15 | AQBvHrUHfO8AJ+wjqV4xB2ILves00X3yu/wZPTey/adkv0Q/SFFcdIi/pL7Mv31F 16 | twxPWWFjLyVfrAlq2mXjAbpqcRseBTNOMymKGVtEh8BB+67Bujk6DxWoDzWdHkaA 17 | iCHgP9FPe0klyMBxUkzNaDMlgvnstCE0BeZWqxvtCvg0d3mt2XPvU5dW6kA0CCFd 18 | WuHtQFmfjipQYYbVYauRpzinPmA6YuF8/cCEY2sC2UDFECf9WpVNPniLZEETTnLs 19 | 2J/LU8BjQzz8m+7FgPFeHrLj5yFZUkN6Dja3qeKto7O6oZcBZvexifbFx0tjULTA 20 | xmSAxLmGcrAn+V/7+w4aRlfo 21 | -----END CERTIFICATE----- 22 | -------------------------------------------------------------------------------- /dist/ssl/web_server.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEowIBAAKCAQEAzRNTQTnSnnoVCVr8wdWjY7iGLEMg8esYHTF6EMdKtiw3/vcL 3 | PciJ94cYxQzmPxXv1i84EWGOVClzKv95dwW9wrV4U+sH7tJhl+lgtgAdDJA9ev1r 4 | 3fV2JaQWZGKWuYSZzXY4cwIn+hZorD28x1ZdEqQGiDmPYv5XBrjOFuLFuXAqelxU 5 | 86MCHyfdRJ5T/XwUcpTPmui/vTV1PTyHefhV3hSuwiFJihsvxtt7Qh1zSb6OA0LY 6 | r5VtP/spQb8zJ5+dTyL8yf/ORZFtJ5JABFYagEN+zrYYFwTQhf/6h/aMlHuWUv5O 7 | AW1QaRASDDw/cZAPKDa4vofqhQ1mhCFKWU4bnwIDAQABAoIBAQCtMuMftWwyuDzI 8 | F/Zc5sgF0rRO8asDZmCJV14WiZqJ3TK1vYPa/GG5knnTAp/7K9XReTPLSi9g2VkR 9 | OY8mfMzVg1pK1bdvdnNCT7KEQ/hEwhWKqDnPzh2okLrwsWtG57zWEECAsZN93ist 10 | PT8Qw9n7gliZ+LMnElQBs1crcP85KwVLASjJvAxiAdoF1SOSea6cOW+nBbUXFyvK 11 | yWtKLASwH79W9AEQCDK36zzhFHRad6a7lHA15SKODaJqFbopA5qDlGMBeV7HEawz 12 | opEXIg4vBqrKJOdO2Q8PCV9H83tWWm7RMOb7CYoX2+pVOHO+7Y/243HmHLA0J4k3 13 | 5tXQkO5BAoGBAObEATJf9w/5owwb+Jo7J0UqMemC4PYfMces3lyyf7lU/WvFWcYT 14 | XLAHqKelFQmMCPBaSZs7qqaiomcVf/xeLqbJ+zMrEK/Ny9E9o9YFT9rrV852EQaL 15 | 4gO/DymhkkfrYfHil4mt5EdrRQKMX8yX5akcdrPcayaiBaRNPktq6FpvAoGBAOOA 16 | KCYWeak+yhLFp8NvxsbWddgFq/Nn9HBT4AC/gCo4dsEVUGE6hPMrr3XhIHm6/Wa8 17 | W017XdZaX8u2DADtuyIqnooNzjb8+RvBT1zAUjIlOGIhwWBy9qFpX2anrk0R3CtP 18 | ITH10/5ktg/H7Q686O0PY1175Qf28CCRvf8JJ6nRAoGAJEOrGHqCPe1yFQYURFCF 19 | dFYUL+kUZzkxvnpJG3Ilpj9X7+a8m+cRCsy5UVcc/joWcYcOyClRQQyPzvlO+p7m 20 | X+mf40OiRK5nmENCivCcwv929ggR1uCGrSYKQPWWIl04MCX2wHkmRZ7y4lqi92jr 21 | e27wrIU4BYMytcY5wupTB1sCgYASmlUuICJcq4y8kjsQqSA4/Cpwuq3/3l1HniQw 22 | C3jAexOC4GpNOQrME6NqYTlVmuvDrd1NbawTrhotPzqmDMqDlbaXFV/qcS8xjNIf 23 | hH50KUT+CUKVz3DJbCNn8og3NyGozPSq8C4gnD2i9rc0wE/PqrV2XH4y84dZMnG1 24 | 3BrJMQKBgHIDfE+ToCMv2vnyeRHGM+igsHvpI+bCtwjUg1UJkdc3w0mQ/At4W1B0 25 | uiajd3fa4cmpXBCF0JYXhpx6GpbhRyODupbeq14LkJ9274BT7oe3toeEd4z9ks3X 26 | 9xSHkQrL621wulItvQFlpZiHy4g3y6IxPU0vjSrfL92pFqw+xG+1 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /dist/tpl/bounce.tpl: -------------------------------------------------------------------------------- 1 | Date: {{.Date}} 2 | From: MAILER-DAEMON@{{.Me}} 3 | To: {{.RcptTo}} 4 | Subject: failure notice 5 | 6 | Hi. This is the tmail deliverd program at {{.Me}} 7 | I'm afraid I wasn't able to deliver your message to the 8 | following addresses. This is a permanent error; I've given up. 9 | Sorry it didn't work out. 10 | 11 | <{{.RcptTo}}>: 12 | {{.ErrMsg}} 13 | 14 | --- Below this line is a copy of the message. 15 | 16 | {{.BouncedMail}} -------------------------------------------------------------------------------- /doc/portBinding.txt: -------------------------------------------------------------------------------- 1 | MAC 2 | https://gist.github.com/gadr/6389682 3 | 4 | LINUX: 5 | http://wiki.apache.org/httpd/NonRootPortBinding 6 | 7 | Solution en python: 8 | https://gist.github.com/methane/7397627 -------------------------------------------------------------------------------- /doc/rfc1869.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/toorop/tmail/a7b1964828ecec1f7d49aa1e318d893b39ca7d60/doc/rfc1869.pdf -------------------------------------------------------------------------------- /doc/rfc1870.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/toorop/tmail/a7b1964828ecec1f7d49aa1e318d893b39ca7d60/doc/rfc1870.pdf -------------------------------------------------------------------------------- /doc/rfc5248.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/toorop/tmail/a7b1964828ecec1f7d49aa1e318d893b39ca7d60/doc/rfc5248.pdf -------------------------------------------------------------------------------- /doc/rfc5321.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/toorop/tmail/a7b1964828ecec1f7d49aa1e318d893b39ca7d60/doc/rfc5321.pdf -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/toorop/tmail 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/boltdb/bolt v1.3.1 7 | github.com/go-sql-driver/mysql v1.6.0 8 | github.com/jinzhu/gorm v1.9.16 9 | github.com/lib/pq v1.1.1 10 | github.com/nsqio/go-nsq v1.1.0 11 | github.com/sirupsen/logrus v1.9.0 12 | github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 13 | github.com/toorop/go-sqlite3 v0.0.0-20150624184432-023bc7af3f7a 14 | github.com/toorop/gopenstack v0.0.0-20180222105328-a83d16339d49 15 | github.com/tredoe/osutil v1.0.6 16 | golang.org/x/crypto v0.0.0-20191205180655-e7c4368fe9dd 17 | ) 18 | 19 | require ( 20 | github.com/blang/semver v3.5.1+incompatible // indirect 21 | github.com/bmizerany/perks v0.0.0-20141205001514-d9a9656a3a4b // indirect 22 | github.com/bradfitz/gomemcache v0.0.0-20221031212613-62deef7fc822 // indirect 23 | github.com/codegangsta/negroni v1.0.0 // indirect 24 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d // indirect 25 | github.com/davecgh/go-spew v1.1.1 // indirect 26 | github.com/golang/snappy v0.0.1 // indirect 27 | github.com/jinzhu/inflection v1.0.0 // indirect 28 | github.com/julienschmidt/httprouter v1.3.0 // indirect 29 | github.com/nbio/httpcontext v0.0.0-20150224063329-d2f7bb023e6e // indirect 30 | github.com/nsqio/go-diskqueue v1.0.0 // indirect 31 | github.com/nsqio/nsq v1.2.1 // indirect 32 | github.com/pmezard/go-difflib v1.0.0 // indirect 33 | github.com/russross/blackfriday/v2 v2.0.1 // indirect 34 | github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect 35 | github.com/stretchr/testify v1.8.1 // indirect 36 | github.com/urfave/cli v1.22.10 // indirect 37 | golang.org/x/sys v0.2.0 // indirect 38 | gopkg.in/yaml.v3 v3.0.1 // indirect 39 | ) 40 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 2 | github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc= 3 | github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= 4 | github.com/bitly/go-hostpool v0.1.0/go.mod h1:4gOCgp6+NZnVqlKyZ/iBZFTAJKembaVENUpMkpg42fw= 5 | github.com/bitly/timer_metrics v1.0.0/go.mod h1:87z4/LSg3f++tMqZwZlsLwPuJu6xloyJ7Qm40NyEkLs= 6 | github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= 7 | github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= 8 | github.com/bmizerany/perks v0.0.0-20141205001514-d9a9656a3a4b h1:AP/Y7sqYicnjGDfD5VcY4CIfh1hRXBUavxrvELjTiOE= 9 | github.com/bmizerany/perks v0.0.0-20141205001514-d9a9656a3a4b/go.mod h1:ac9efd0D1fsDb3EJvhqgXRbFx7bs2wqZ10HQPeU8U/Q= 10 | github.com/boltdb/bolt v1.3.1 h1:JQmyP4ZBrce+ZQu0dY660FMfatumYDLun9hBCUVIkF4= 11 | github.com/boltdb/bolt v1.3.1/go.mod h1:clJnj/oiGkjum5o1McbSZDSLxVThjynRyGBgiAx27Ps= 12 | github.com/bradfitz/gomemcache v0.0.0-20221031212613-62deef7fc822 h1:hjXJeBcAMS1WGENGqDpzvmgS43oECTx8UXq31UBu0Jw= 13 | github.com/bradfitz/gomemcache v0.0.0-20221031212613-62deef7fc822/go.mod h1:H0wQNHz2YrLsuXOZozoeDmnHXkNCRmMW0gwFWDfEZDA= 14 | github.com/codegangsta/negroni v1.0.0 h1:+aYywywx4bnKXWvoWtRfJ91vC59NbEhEY03sZjQhbVY= 15 | github.com/codegangsta/negroni v1.0.0/go.mod h1:v0y3T5G7Y1UlFfyxFn/QLRU4a2EuNau2iZY63YTKWo0= 16 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= 17 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 18 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 19 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 20 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 21 | github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= 22 | github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0= 23 | github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= 24 | github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= 25 | github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= 26 | github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= 27 | github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4= 28 | github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 29 | github.com/jinzhu/gorm v1.9.16 h1:+IyIjPEABKRpsu/F8OvDPy9fyQlgsg2luMV2ZIH5i5o= 30 | github.com/jinzhu/gorm v1.9.16/go.mod h1:G3LB3wezTOWM2ITLzPxEXgSkOXAntiLHS7UdBefADcs= 31 | github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= 32 | github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 33 | github.com/jinzhu/now v1.0.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 34 | github.com/judwhite/go-svc v1.2.1/go.mod h1:mo/P2JNX8C07ywpP9YtO2gnBgnUiFTHqtsZekJrUuTk= 35 | github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= 36 | github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= 37 | github.com/lib/pq v1.1.1 h1:sJZmqHoEaY7f+NPP8pgLB/WxulyR3fewgCM2qaSlBb4= 38 | github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= 39 | github.com/mattn/go-sqlite3 v1.14.0/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71ShQilpsus= 40 | github.com/mreiferson/go-options v1.0.0/go.mod h1:zHtCks/HQvOt8ATyfwVe3JJq2PPuImzXINPRTC03+9w= 41 | github.com/nbio/httpcontext v0.0.0-20150224063329-d2f7bb023e6e h1:xvCbHT5A8/Tv15eVLfprM7nvLo/VQyx35X38RW86JVA= 42 | github.com/nbio/httpcontext v0.0.0-20150224063329-d2f7bb023e6e/go.mod h1:VR24Qkx+arWGHg5MtQ748xm0olYqbOiN35k1m1sUBaw= 43 | github.com/nsqio/go-diskqueue v1.0.0 h1:XRqpx7zTMu9yNVH+cHvA5jEiPNKoYcyEsCVqXP3eFg4= 44 | github.com/nsqio/go-diskqueue v1.0.0/go.mod h1:INuJIxl4ayUsyoNtHL5+9MFPDfSZ0zY93hNY6vhBRsI= 45 | github.com/nsqio/go-nsq v1.0.8/go.mod h1:vKq36oyeVXgsS5Q8YEO7WghqidAVXQlcFxzQbQTuDEY= 46 | github.com/nsqio/go-nsq v1.1.0 h1:PQg+xxiUjA7V+TLdXw7nVrJ5Jbl3sN86EhGCQj4+FYE= 47 | github.com/nsqio/go-nsq v1.1.0/go.mod h1:vKq36oyeVXgsS5Q8YEO7WghqidAVXQlcFxzQbQTuDEY= 48 | github.com/nsqio/nsq v1.2.1 h1:ZVjANYLnX1vPLmuSNCOdiw4nNPnzWgAC4t8wFhznMqU= 49 | github.com/nsqio/nsq v1.2.1/go.mod h1:vXbwehoIygyVoX44oLFaN7MA0xrmudeuborDpMPiLTY= 50 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 51 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 52 | github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= 53 | github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 54 | github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= 55 | github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 56 | github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0= 57 | github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 58 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 59 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 60 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 61 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 62 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 63 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 64 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 65 | github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= 66 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 67 | github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 h1:PM5hJF7HVfNWmCjMdEfbuOBNXSVF2cMFGgQTPdKCbwM= 68 | github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns= 69 | github.com/toorop/go-sqlite3 v0.0.0-20150624184432-023bc7af3f7a h1:peEwdT+E1tLsiexTt+BFSfCtnjdFf9F/bJXyB3Am+bo= 70 | github.com/toorop/go-sqlite3 v0.0.0-20150624184432-023bc7af3f7a/go.mod h1:BQ0Z2Ug9aW8KJT4zMeVjEemTEkVlOWpQv+sHdjy0zvI= 71 | github.com/toorop/gopenstack v0.0.0-20180222105328-a83d16339d49 h1:tx49TfQ+FkI4KFG27OEx3gMvLWfSduEGvs3qeBOBa+4= 72 | github.com/toorop/gopenstack v0.0.0-20180222105328-a83d16339d49/go.mod h1:ZqDjWuyu5Vu4YJmhGSOAEwKdZYEZ226+wnE6lLabWsg= 73 | github.com/tredoe/fileutil v1.0.5/go.mod h1:HFzzpvg+3Q8LgmZgo1mVF5epHc/CVkWKEb3hja+/1Zo= 74 | github.com/tredoe/goutil v1.0.0/go.mod h1:Qhf75QLcNEChimbl4wb8nROzw9PCFCPYTEUmTnoszXY= 75 | github.com/tredoe/osutil v1.0.6 h1:KJvG9AFmUPLe3hsNKyPMIjNx77CkAJtMKVS4ugAT7vM= 76 | github.com/tredoe/osutil v1.0.6/go.mod h1:zNq93p2DLHJWkHi2/+zi3xOjZl8xxiv3tiI2A6zcB3w= 77 | github.com/urfave/cli v1.22.10 h1:p8Fspmz3iTctJstry1PYS3HVdllxnEzTEsgIgtxTrCk= 78 | github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= 79 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 80 | golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 81 | golang.org/x/crypto v0.0.0-20191205180655-e7c4368fe9dd h1:GGJVjV8waZKRHrgwvtH66z9ZGVurTD1MT0n1Bb+q4aM= 82 | golang.org/x/crypto v0.0.0-20191205180655-e7c4368fe9dd/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 83 | golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 84 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 85 | golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 86 | golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 87 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 88 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 89 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 90 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 91 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 92 | golang.org/x/sys v0.2.0 h1:ljd4t30dBnAvMZaQCevtY0xLLD0A+bRZXbgLMLU1F/A= 93 | golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 94 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 95 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 96 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 97 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 98 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 99 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 100 | -------------------------------------------------------------------------------- /message/envelope.go: -------------------------------------------------------------------------------- 1 | package message 2 | 3 | // Envelope reprsente a message envelope 4 | type Envelope struct { 5 | MailFrom string 6 | RcptTo []string 7 | } 8 | 9 | func (e Envelope) String() string { 10 | out := "F" + e.MailFrom + "\000" 11 | for _, rcpt := range e.RcptTo { 12 | out += "T" + rcpt + "\000" 13 | } 14 | return out + "\000" 15 | } 16 | -------------------------------------------------------------------------------- /message/errors.go: -------------------------------------------------------------------------------- 1 | package message 2 | 3 | import ( 4 | "errors" 5 | ) 6 | 7 | var ( 8 | // ErrNonAsciiCharDetected when an email body does not contain only 7 bits ascii char 9 | ErrNonAsciiCharDetected = errors.New("email must contains only 7-bit ASCII characters") 10 | ) 11 | -------------------------------------------------------------------------------- /message/message.go: -------------------------------------------------------------------------------- 1 | package message 2 | 3 | import ( 4 | "bytes" 5 | "io/ioutil" 6 | "net/mail" 7 | "net/textproto" 8 | "regexp" 9 | //"os" 10 | "strings" 11 | ) 12 | 13 | // message represents an email message 14 | type Message struct { 15 | mail.Message 16 | } 17 | 18 | func New(rawmail *[]byte) (m *Message, err error) { 19 | m = &Message{} 20 | reader := bytes.NewReader(*rawmail) 21 | // TODO: refactor 22 | t, err := mail.ReadMessage(reader) 23 | if err != nil { 24 | return 25 | } 26 | m.Body = t.Body 27 | m.Header = t.Header 28 | return 29 | } 30 | 31 | // heaveHeader check the existence of header header 32 | func (m *Message) HaveHeader(key string) bool { 33 | key = textproto.CanonicalMIMEHeaderKey(key) 34 | if len(m.Header.Get(key)) == 0 { 35 | return false 36 | } 37 | return true 38 | } 39 | 40 | // addheader add an header 41 | func (m *Message) AddHeader(key, value string) { 42 | key = textproto.CanonicalMIMEHeaderKey(key) 43 | m.Header[key] = append(m.Header[key], value) 44 | return 45 | } 46 | 47 | // Set sets the header entries associated with key to 48 | // the single element value. It replaces any existing 49 | // values associated with key. 50 | func (m *Message) SetHeader(key, value string) { 51 | m.Header[textproto.CanonicalMIMEHeaderKey(key)] = []string{value} 52 | } 53 | 54 | // delHeader deletes the values associated with key. 55 | func (m *Message) DelHeader(key string) { 56 | delete(m.Header, textproto.CanonicalMIMEHeaderKey(key)) 57 | } 58 | 59 | // getHeader get one header, or the first occurence if there is multipke headers with this key 60 | func (m *Message) GetHeader(key string) string { 61 | return m.Header.Get(key) 62 | } 63 | 64 | // getHeaders returns all the headers corresponding to the key key 65 | func (m *Message) GetHeaders(key string) []string { 66 | return m.Header[textproto.CanonicalMIMEHeaderKey(key)] 67 | } 68 | 69 | // getRaw returns raw message 70 | // some cleanup are made 71 | // wrap headers line to 999 char max 72 | func (m *Message) GetRaw() (rawMessage []byte, err error) { 73 | rawMessage = []byte{} 74 | // Header 75 | for key, hs := range m.Header { 76 | // clean key 77 | key = textproto.CanonicalMIMEHeaderKey(key) 78 | for _, value := range hs { 79 | //println("Les headers avant traitement: " + key + " -> " + value) 80 | // TODO clean value 81 | // split at 900 82 | // remove unsuported char 83 | // 84 | // On ne doit pas avoir autre chose que des char < 128 85 | // Attention si un jour on implemente l'extension SMTPUTF8 86 | // Voir RFC 6531 (SMTPUTF8 extension), RFC 6532 (Internationalized email headers) and RFC 6533 (Internationalized delivery status notifications). 87 | /*for _, c := range value { 88 | if c > 128 { 89 | return rawMessage, ErrNonAsciiCharDetected 90 | } 91 | }*/ 92 | 93 | // Fold header 94 | //t := FoldHeader(key+": "+value) + "\r\n" 95 | //println("\nHeaders apres traitement: " + t) 96 | newHeader := []byte(key + ": " + value) 97 | FoldHeader(&newHeader) 98 | rawMessage = append(rawMessage, newHeader...) 99 | rawMessage = append(rawMessage, []byte{13, 10}...) 100 | 101 | } 102 | } 103 | 104 | rawMessage = append(rawMessage, []byte{13, 10}...) 105 | 106 | // Body 107 | b, err := ioutil.ReadAll(m.Body) 108 | if err != nil { 109 | return 110 | } 111 | rawMessage = append(rawMessage, b...) 112 | return 113 | } 114 | 115 | // helpers 116 | 117 | // getHostFromAddress returns host part from an email address 118 | // Warning this check assume to get a valid email address 119 | func GetHostFromAddress(address string) string { 120 | address = strings.ToLower(address) 121 | return address[strings.Index(address, "@")+1:] 122 | } 123 | 124 | // FoldHeader retun header value according to RFC 2822 125 | // https://tools.ietf.org/html/rfc2822#section-2.1.1 126 | // There are two limits that this standard places on the number of 127 | // characters in a line. Each line of characters MUST be no more than 128 | // 998 characters, and SHOULD be no more than 78 characters, excluding 129 | // the CRLF. 130 | // TODO: refactor Foldheader 131 | func FoldHeader(header *[]byte) { 132 | 133 | raw := *header 134 | 135 | rxReduceWS := regexp.MustCompile(`[ \t]+`) 136 | 137 | // remove \r & \n 138 | raw = bytes.Replace(raw, []byte{13}, []byte{}, -1) 139 | raw = bytes.Replace(raw, []byte{10}, []byte{}, -1) 140 | raw = rxReduceWS.ReplaceAll(raw, []byte(" ")) 141 | if len(raw) < 78 { 142 | *header = raw 143 | return 144 | } 145 | lastCut := 0 146 | lastSpace := 0 147 | headerLenght := 0 148 | spacesSeen := 0 149 | *header = []byte{} 150 | 151 | for i, c := range raw { 152 | headerLenght++ 153 | // espace 154 | if c == 32 { 155 | // si ce n'est pas l'espace qui suit le header 156 | if spacesSeen != 0 { 157 | lastSpace = i 158 | } 159 | spacesSeen++ 160 | } 161 | if headerLenght > 77 { 162 | if len(*header) != 0 { 163 | *header = append(*header, []byte{13, 10, 32, 32}...) 164 | } 165 | *header = append(*header, raw[lastCut:lastSpace]...) 166 | lastCut = lastSpace 167 | headerLenght = 0 168 | } 169 | } 170 | if len(*header) != 0 && lastCut < len(raw) { 171 | *header = append(*header, []byte{13, 10, 32, 32}...) 172 | } 173 | *header = append(*header, raw[lastCut:]...) 174 | return 175 | } 176 | -------------------------------------------------------------------------------- /message/message_test.go: -------------------------------------------------------------------------------- 1 | package message 2 | 3 | import ( 4 | //"fmt" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | const ( 11 | header1 = `Received: 12 | from 209.85.215.52 13 | (mail-la0-f52.google.com.) (mail-la0-f52.google.com) by 5.196.15.145 (mail.tmail.io.) with ESMTPS; 14 | 15 | 16 | 17 | 18 | 22 May 2015 09:21:35 +0200; tmail 0.0.8; 1887bff38a4d7f7c0fff14f82cdb3f0054c9caf4 19 | 20 | 21 | 22 | 23 | 24 | ` 25 | ) 26 | 27 | func Test_FoldHeader(t *testing.T) { 28 | header := []byte(header1) 29 | FoldHeader(&header) 30 | println(string(header)) 31 | assert.NotEmpty(t, header) 32 | } 33 | -------------------------------------------------------------------------------- /message/raw.go: -------------------------------------------------------------------------------- 1 | // utilities to work on raw message 2 | package message 3 | 4 | import ( 5 | "bytes" 6 | "net/textproto" 7 | "strings" 8 | ) 9 | 10 | // RawGetHeaders return raw headers 11 | func RawGetHeaders(raw *[]byte) []byte { 12 | return bytes.Split(*raw, []byte{13, 10, 13, 10})[0] 13 | } 14 | 15 | // RawHaveHeader check igf header header is present in raw mail 16 | func RawHaveHeader(raw *[]byte, header string) bool { 17 | var bHeader []byte 18 | if strings.ToLower(header) == "message-id" { 19 | bHeader = []byte("Message-ID") 20 | } else { 21 | bHeader = []byte(textproto.CanonicalMIMEHeaderKey(header)) 22 | } 23 | for _, line := range bytes.Split(RawGetHeaders(raw), []byte{13, 10}) { 24 | if bytes.HasPrefix(line, bHeader) { 25 | return true 26 | } 27 | } 28 | return false 29 | } 30 | 31 | // RawGetMessageId return Message-ID or empty string if to found 32 | func RawGetMessageId(raw *[]byte) []byte { 33 | bHeader := []byte("message-id") 34 | for _, line := range bytes.Split(RawGetHeaders(raw), []byte{13, 10}) { 35 | if bytes.HasPrefix(bytes.ToLower(line), bHeader) { 36 | // strip <> 37 | p := bytes.SplitN(line, []byte{58}, 2) 38 | return bytes.TrimPrefix(bytes.TrimSuffix(bytes.TrimSpace(p[1]), []byte{62}), []byte{60}) 39 | } 40 | } 41 | return []byte{} 42 | } 43 | -------------------------------------------------------------------------------- /plugin_import.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import _ "github.com/toorop/tmail/plugins/connect" 4 | 5 | //import _ "github.com/toorop/protecmail/tmailplugins/postinit" 6 | 7 | //import _ "github.com/toorop/protecmail/tmailplugins/connect" 8 | 9 | //import _ "github.com/toorop/protecmail/tmailplugins/helo" 10 | 11 | //import _ "github.com/toorop/protecmail/tmailplugins/rcptto" 12 | 13 | //import _ "github.com/toorop/protecmail/tmailplugins/deliverd_remoteinit" 14 | -------------------------------------------------------------------------------- /plugins/connect/main.go: -------------------------------------------------------------------------------- 1 | package connect 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "strings" 7 | 8 | "github.com/bradfitz/gomemcache/memcache" 9 | tmail "github.com/toorop/tmail/core" 10 | ) 11 | 12 | // note for the poc all variables are hardcoder 13 | // TODO handle config 14 | 15 | const ( 16 | memcacheServer = "127.0.0.1:11211" 17 | recordExpiration = int32(3600) 18 | ) 19 | 20 | var ( 21 | mc *memcache.Client 22 | RBLs = []string{"bl.spamcop.net"} 23 | ) 24 | 25 | // whitelist 26 | var whitelist = []string{ 27 | // localhost 28 | "127.0.", 29 | "192.168.", 30 | "163.172.180.201", 31 | // google 32 | "35.190.247.", 33 | "64.233.160.", 34 | "66.102.", 35 | "66.249.80.", 36 | "72.14.192.", 37 | "74.125.", 38 | "108.177.8.", 39 | "173.194.", 40 | "209.85.128.", 41 | "216.58.192.", 42 | "216.239.32.", 43 | "172.217.", 44 | "172.217.32.", 45 | "172.217.128.", 46 | "172.217.160.", 47 | "172.217.192.", 48 | "172.253.56.", 49 | "172.253.112.", 50 | "108.177.96.", 51 | "35.191.", 52 | "130.211.", 53 | "64.18.", 54 | "64.233.160.", 55 | "66.102.", 56 | "66.249.80.", 57 | "72.14.192.", 58 | "74.125.", 59 | "108.177.8.", 60 | "188.165.", 61 | "173.194.", 62 | "207.126.144.", 63 | "209.85.", 64 | "216.58.192.", 65 | "216.239.32.", 66 | "172.217.", 67 | // OVH 68 | "176.31.", 69 | "83.136.", 70 | "178.32.", 71 | "178.33.", 72 | "188.165.", 73 | "46.105.", 74 | "92.243.19.235", 75 | "87.98.", 76 | "213.186.33.", 77 | "51.254.194.", 78 | "79.137.114.", 79 | // Orange 80 | "80.12", 81 | "193.252.", 82 | // Gandi 83 | "217.70.", 84 | // Infomaniak 85 | "128.65.195.4", 86 | "128.65.195.5", 87 | "128.65.195.6", 88 | // Mailjet 89 | "185.189.", 90 | "185.211.", 91 | "185.250.", 92 | "87.253.", 93 | // Microsoft 94 | "13.107.", 95 | "23.103.", 96 | "40.92.", 97 | "40.96.", 98 | "40.107.", 99 | "64.4.22.", 100 | "65.55.", 101 | "94.245.120.", 102 | "104.47.0.", 103 | "132.245.", 104 | "134.170.140.", 105 | "157.55.", 106 | "157.56.", 107 | "191.232.", 108 | "191.234.", 109 | "204.79.", 110 | "207.46.", 111 | "213.199.154.", 112 | "216.32.180.", 113 | // free 114 | "212.27.", 115 | "213.228.", 116 | // Oleane 117 | "2.161.", 118 | "62.161.", 119 | // SFR 120 | "93.17.128.", 121 | "212.27.", 122 | "80.125.182.", 123 | } 124 | 125 | // init: register plugin 126 | func init() { 127 | tmail.RegisterSMTPdPlugin("connect", Plugin) 128 | mc = memcache.New(memcacheServer) 129 | } 130 | 131 | // Plugin main plugin fucntion 132 | func Plugin(s *tmail.SMTPServerSession) bool { 133 | var msg string 134 | 135 | clientIP := strings.Split(s.Conn.RemoteAddr().String(), ":")[0] 136 | s.LogDebug(fmt.Sprintf(" smtpwall - remote IP %s", clientIP)) 137 | 138 | // if in whitelist continue 139 | if isInWhitelist(clientIP) { 140 | return false 141 | } 142 | 143 | // check if IP is in smtpwall blacklist 144 | blacklisted := isInBl(clientIP) 145 | if blacklisted { 146 | msg = "471 your IP (" + clientIP + ") is temporarily blacklisted due to bad behavior. Try again later" 147 | s.Log(msg) 148 | s.Out(msg) 149 | s.ExitAsap() 150 | return true 151 | } 152 | 153 | // Check if IP have reverse 154 | haveReverse, _, err := getReverse(clientIP) 155 | if err != nil { 156 | s.LogError(fmt.Sprintf(" smtpwall - getReverse failed - %s", err)) 157 | } 158 | if !haveReverse { 159 | if err = putInBl(clientIP); err != nil { 160 | s.LogError("smtpwall - putInBl failed -" + err.Error()) 161 | } 162 | msg = "471 - your IP (" + clientIP + ") have no reverse fix it and try later" 163 | s.Log(msg) 164 | s.Out(msg) 165 | s.ExitAsap() 166 | return true 167 | } 168 | 169 | // check if IP is blacklisted in RBL 170 | for _, rbl := range RBLs { 171 | if isBlacklistedIn(clientIP, rbl) { 172 | if err = putInBl(clientIP); err != nil { 173 | s.LogError("smtpwall - putInBl failed -" + err.Error()) 174 | } 175 | msg := "471 your ip (" + clientIP + ") is blacklisted on " + rbl + " fix it and try later" 176 | s.Log(msg) 177 | s.Out(msg) 178 | s.ExitAsap() 179 | return true 180 | } 181 | } 182 | 183 | return false 184 | } 185 | 186 | // isInWhitelist checks if IP is in whitelist 187 | func isInWhitelist(ip string) bool { 188 | for _, wl := range whitelist { 189 | if strings.HasPrefix(ip, wl) { 190 | return true 191 | } 192 | } 193 | return false 194 | } 195 | 196 | // getReverse returns IP reverse 197 | func getReverse(ip string) (bool, string, error) { 198 | hosts, err := net.LookupAddr(ip) 199 | tcpRemoteHost := "unknow" 200 | if err != nil { 201 | if !strings.HasSuffix(err.Error(), "misbehaving") { 202 | return false, tcpRemoteHost, nil 203 | } 204 | return true, tcpRemoteHost, err 205 | } 206 | if len(hosts) >= 0 { 207 | tcpRemoteHost = hosts[0] 208 | } 209 | return true, tcpRemoteHost, nil 210 | } 211 | 212 | // check if ip is blacklisted in rbl 213 | func isBlacklistedIn(ip, rbl string) bool { 214 | // reverse ip 215 | p := strings.Split(ip, ".") 216 | var toCheck string 217 | for _, part := range p { 218 | toCheck = part + "." + toCheck 219 | } 220 | _, err := net.LookupHost(toCheck + rbl) 221 | return err == nil 222 | } 223 | 224 | // putInBl add an IP to our local blacklist 225 | func putInBl(ip string) error { 226 | return mc.Set(&memcache.Item{ 227 | Key: ip, 228 | Value: []byte(ip), 229 | Expiration: recordExpiration, 230 | }) 231 | } 232 | 233 | // isInBl check if IP is in our local blacklist 234 | func isInBl(ip string) bool { 235 | _, err := mc.Get(ip) 236 | if err == nil { 237 | return true 238 | } 239 | return false 240 | } 241 | -------------------------------------------------------------------------------- /rest/auth.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | import ( 4 | "bytes" 5 | "encoding/base64" 6 | "net/http" 7 | 8 | "github.com/toorop/tmail/core" 9 | ) 10 | 11 | // ServeHTTP implementation of interface 12 | func authorized(w http.ResponseWriter, r *http.Request) bool { 13 | // Headers Authorization found ? 14 | hAuth := r.Header.Get("authorization") 15 | if hAuth == "" { 16 | w.Header().Set("WWW-Authenticate", "Basic realm=tmail REST server") 17 | http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) 18 | return false 19 | } 20 | // check credential 21 | if hAuth[:5] != "Basic" { 22 | http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) 23 | return false 24 | } 25 | decoded, err := base64.StdEncoding.DecodeString(hAuth[6:]) 26 | if err != nil { 27 | logError(r, "on decoding http auth credentials:", err.Error()) 28 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 29 | return false 30 | } 31 | credentials := bytes.SplitN(decoded, []byte{58}, 2) 32 | 33 | if bytes.Compare([]byte(core.Cfg.GetRestServerLogin()), credentials[0]) != 0 || bytes.Compare([]byte(core.Cfg.GetRestServerPasswd()), credentials[1]) != 0 { 34 | logError(r, "bad authentification. Login:", string(credentials[0]), "password:", string(credentials[1])) 35 | http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) 36 | return false 37 | } 38 | return true 39 | } 40 | -------------------------------------------------------------------------------- /rest/auth_test.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "io/ioutil" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | "github.com/toorop/tmail/config" 11 | "github.com/toorop/tmail/logger" 12 | "github.com/toorop/tmail/scope" 13 | ) 14 | 15 | func Test_authorized(t *testing.T) { 16 | var err error 17 | assert := assert.New(t) 18 | scope.Cfg = new(config.Config) 19 | scope.Log, err = logger.New(ioutil.Discard, false) 20 | assert.NoError(err) 21 | scope.Cfg.SetRestServerLogin("good") 22 | scope.Cfg.SetRestServerPasswd("good") 23 | 24 | // no Auth 25 | w := httptest.NewRecorder() 26 | r, _ := http.NewRequest("GET", "http://localhost/foobar", nil) 27 | assert.False(authorized(w, r)) 28 | assert.Equal(w.Code, http.StatusUnauthorized) 29 | assert.Equal(w.Header().Get("WWW-Authenticate"), "Basic realm=tmail REST server") 30 | 31 | // bad auth 32 | r.SetBasicAuth("bad", "bad") 33 | assert.False(authorized(w, r)) 34 | r.SetBasicAuth("good", "bad") 35 | assert.False(authorized(w, r)) 36 | r.SetBasicAuth("bad", "good") 37 | assert.False(authorized(w, r)) 38 | assert.Equal(w.Code, http.StatusUnauthorized) 39 | 40 | // good auth 41 | w = httptest.NewRecorder() 42 | r.SetBasicAuth("good", "good") 43 | assert.True(authorized(w, r)) 44 | assert.Equal(w.Code, 200) 45 | } 46 | -------------------------------------------------------------------------------- /rest/handlers_queue.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "strconv" 7 | 8 | "github.com/jinzhu/gorm" 9 | "github.com/julienschmidt/httprouter" 10 | "github.com/nbio/httpcontext" 11 | "github.com/toorop/tmail/api" 12 | ) 13 | 14 | // usersGetAll return all users 15 | func queueGetMessages(w http.ResponseWriter, r *http.Request) { 16 | if !authorized(w, r) { 17 | return 18 | } 19 | messages, err := api.QueueGetMessages() 20 | if err != nil { 21 | httpWriteErrorJson(w, 500, "unable to get message in queue", err.Error()) 22 | return 23 | } 24 | js, err := json.Marshal(messages) 25 | if err != nil { 26 | httpWriteErrorJson(w, 500, "JSON encondig failed", err.Error()) 27 | return 28 | } 29 | httpWriteJson(w, js) 30 | } 31 | 32 | // queueGetMessage get a message by ID 33 | func queueGetMessage(w http.ResponseWriter, r *http.Request) { 34 | if !authorized(w, r) { 35 | return 36 | } 37 | msgIdStr := httpcontext.Get(r, "params").(httprouter.Params).ByName("id") 38 | msgIdInt, err := strconv.ParseInt(msgIdStr, 10, 64) 39 | if err != nil { 40 | httpWriteErrorJson(w, 500, "unable to get message id", err.Error()) 41 | return 42 | } 43 | 44 | m, err := api.QueueGetMessage(msgIdInt) 45 | if err == gorm.ErrRecordNotFound { 46 | httpWriteErrorJson(w, 404, "no such message "+msgIdStr, "") 47 | return 48 | } 49 | if err != nil { 50 | httpWriteErrorJson(w, 500, "unable to get message "+msgIdStr, err.Error()) 51 | return 52 | } 53 | js, err := json.Marshal(m) 54 | if err != nil { 55 | httpWriteErrorJson(w, 500, "unable to get message "+msgIdStr, err.Error()) 56 | return 57 | } 58 | httpWriteJson(w, js) 59 | } 60 | 61 | // queueDiscardMessage discard a message (delete without bouncing) 62 | func queueDiscardMessage(w http.ResponseWriter, r *http.Request) { 63 | if !authorized(w, r) { 64 | return 65 | } 66 | msgIdStr := httpcontext.Get(r, "params").(httprouter.Params).ByName("id") 67 | msgIdInt, err := strconv.ParseInt(msgIdStr, 10, 64) 68 | if err != nil { 69 | httpWriteErrorJson(w, 500, "unable to get message id", err.Error()) 70 | return 71 | } 72 | err = api.QueueDiscardMsg(msgIdInt) 73 | if err == gorm.ErrRecordNotFound { 74 | httpWriteErrorJson(w, 404, "no such message "+msgIdStr, "") 75 | return 76 | } 77 | if err != nil { 78 | httpWriteErrorJson(w, 500, "unable to discard message "+msgIdStr, err.Error()) 79 | return 80 | } 81 | } 82 | 83 | // queueBounceMessage bounce a message 84 | func queueBounceMessage(w http.ResponseWriter, r *http.Request) { 85 | if !authorized(w, r) { 86 | return 87 | } 88 | msgIdStr := httpcontext.Get(r, "params").(httprouter.Params).ByName("id") 89 | msgIdInt, err := strconv.ParseInt(msgIdStr, 10, 64) 90 | if err != nil { 91 | httpWriteErrorJson(w, 500, "unable to get message id", err.Error()) 92 | return 93 | } 94 | err = api.QueueBounceMsg(msgIdInt) 95 | if err == gorm.ErrRecordNotFound { 96 | httpWriteErrorJson(w, 404, "no such message "+msgIdStr, "") 97 | return 98 | } 99 | if err != nil { 100 | httpWriteErrorJson(w, 500, "unable to bounce message "+msgIdStr, err.Error()) 101 | return 102 | } 103 | } 104 | 105 | // addQueueHandlers add Queue handlers to router 106 | func addQueueHandlers(router *httprouter.Router) { 107 | // get all message in queue 108 | router.GET("/queue", wrapHandler(queueGetMessages)) 109 | // get a message by id 110 | router.GET("/queue/:id", wrapHandler(queueGetMessage)) 111 | // discard a message 112 | router.DELETE("/queue/discard/:id", wrapHandler(queueDiscardMessage)) 113 | // bounce a message 114 | router.DELETE("/queue/bounce/:id", wrapHandler(queueBounceMessage)) 115 | } 116 | -------------------------------------------------------------------------------- /rest/handlers_test.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "github.com/julienschmidt/httprouter" 8 | "github.com/nbio/httpcontext" 9 | "github.com/stretchr/testify/assert" 10 | "io/ioutil" 11 | "net/http" 12 | "net/http/httptest" 13 | "testing" 14 | "time" 15 | 16 | "github.com/toorop/tmail/core" 17 | "github.com/toorop/tmail/logger" 18 | "github.com/toorop/tmail/scope" 19 | ) 20 | 21 | func TestHandlerUSers(t *testing.T) { 22 | var err error 23 | login := "test@tmail.io" 24 | assert := assert.New(t) 25 | assert.NoError(scope.Init()) 26 | scope.Log, err = logger.New(ioutil.Discard, false) 27 | assert.NoError(err) 28 | 29 | // drop table users 30 | assert.NoError(scope.DB.DropTableIfExists(&core.User{}).Error) 31 | assert.NoError(scope.DB.AutoMigrate(&core.User{}).Error) 32 | 33 | // Get all users should return empty json array 34 | w := httptest.NewRecorder() 35 | r, _ := http.NewRequest("GET", "http://localhost/foobar", nil) 36 | r.SetBasicAuth("admin", "admin") 37 | 38 | usersGetAll(w, r) 39 | 40 | b, _ := ioutil.ReadAll(w.Body) 41 | assert.Equal(200, w.Code, string(b)) 42 | assert.Equal("[]", string(b)) 43 | 44 | // Add user 45 | w = httptest.NewRecorder() 46 | r, _ = http.NewRequest("POST", "http://localhost/foobar", bytes.NewBufferString(`{"passwd": "passwd", "authRelay": true, "haveMailbox": true, "mailboxQuota": "1G"}`)) 47 | r.SetBasicAuth("admin", "admin") 48 | ps := httprouter.Params{ 49 | httprouter.Param{"user", login}, 50 | } 51 | httpcontext.Set(r, "params", ps) 52 | 53 | usersAdd(w, r) 54 | b, _ = ioutil.ReadAll(w.Body) 55 | assert.Equal(201, w.Code, string(b)) 56 | 57 | // Get users; should return one user 58 | w = httptest.NewRecorder() 59 | r, _ = http.NewRequest("GET", "http://localhost/foobar", nil) 60 | r.SetBasicAuth("admin", "admin") 61 | usersGetAll(w, r) 62 | b, _ = ioutil.ReadAll(w.Body) 63 | assert.Equal(200, w.Code, string(b)) 64 | assert.NotEqual("[]", string(b)) 65 | u := core.User{} 66 | assert.NoError(json.NewDecoder(bytes.NewReader(b[1 : len(b)-1])).Decode(&u)) 67 | assert.Equal(login, u.Login) 68 | assert.Equal(true, u.AuthRelay) 69 | assert.Equal(true, u.HaveMailbox) 70 | assert.Equal("1G", u.MailboxQuota) 71 | 72 | // Get One users; should return one user 73 | w = httptest.NewRecorder() 74 | r, _ = http.NewRequest("GET", "http://localhost/foobar", nil) 75 | r.SetBasicAuth("admin", "admin") 76 | ps = httprouter.Params{ 77 | httprouter.Param{"user", login}, 78 | } 79 | httpcontext.Set(r, "params", ps) 80 | usersGetOne(w, r) 81 | b, _ = ioutil.ReadAll(w.Body) 82 | assert.Equal(200, w.Code, string(b)) 83 | assert.NotEqual("[]", string(b)) 84 | u = core.User{} 85 | assert.NoError(json.NewDecoder(bytes.NewReader(b)).Decode(&u)) 86 | assert.Equal(login, u.Login) 87 | assert.Equal(true, u.AuthRelay) 88 | assert.Equal(true, u.HaveMailbox) 89 | assert.Equal("1G", u.MailboxQuota) 90 | 91 | // Del user 92 | w = httptest.NewRecorder() 93 | r, _ = http.NewRequest("DELETE", "http://localhost/foobar", nil) 94 | r.SetBasicAuth("admin", "admin") 95 | ps = httprouter.Params{ 96 | httprouter.Param{"user", login}, 97 | } 98 | httpcontext.Set(r, "params", ps) 99 | usersDel(w, r) 100 | assert.Equal(200, w.Code) 101 | } 102 | 103 | func TestHandlerQueue(t *testing.T) { 104 | var err error 105 | assert := assert.New(t) 106 | assert.NoError(scope.Init()) 107 | scope.Log, err = logger.New(ioutil.Discard, false) 108 | assert.NoError(err) 109 | 110 | // drop table queue 111 | assert.NoError(scope.DB.DropTableIfExists(&core.QMessage{}).Error) 112 | assert.NoError(scope.DB.AutoMigrate(&core.QMessage{}).Error) 113 | 114 | // Get all message in queue should return empty json array 115 | w := httptest.NewRecorder() 116 | r, _ := http.NewRequest("GET", "http://localhost/foobar", nil) 117 | r.SetBasicAuth("admin", "admin") 118 | queueGetMessages(w, r) 119 | b, _ := ioutil.ReadAll(w.Body) 120 | assert.Equal(200, w.Code, string(b)) 121 | assert.Equal("[]", string(b)) 122 | 123 | // Add message 124 | message := core.QMessage{ 125 | Uuid: "uuid", 126 | Key: "key", 127 | AddedAt: time.Now(), 128 | Status: 2, 129 | DeliveryFailedCount: 0, 130 | } 131 | assert.NoError(scope.DB.Create(&message).Error) 132 | 133 | // Get all message 134 | w = httptest.NewRecorder() 135 | r, _ = http.NewRequest("GET", "http://localhost/foobar", nil) 136 | r.SetBasicAuth("admin", "admin") 137 | queueGetMessages(w, r) 138 | b, _ = ioutil.ReadAll(w.Body) 139 | assert.Equal(200, w.Code, string(b)) 140 | assert.NotEqual("[]", string(b)) 141 | m := core.QMessage{} 142 | assert.NoError(json.NewDecoder(bytes.NewReader(b[1 : len(b)-1])).Decode(&m)) 143 | assert.Equal(m.Uuid, "uuid") 144 | 145 | // Get one 146 | w = httptest.NewRecorder() 147 | r, _ = http.NewRequest("GET", "http://localhost/foobar", nil) 148 | r.SetBasicAuth("admin", "admin") 149 | ps := httprouter.Params{ 150 | httprouter.Param{"id", fmt.Sprintf("%d", m.Id)}, 151 | } 152 | httpcontext.Set(r, "params", ps) 153 | queueGetMessage(w, r) 154 | b, _ = ioutil.ReadAll(w.Body) 155 | assert.Equal(200, w.Code, string(b)) 156 | assert.NotEqual("[]", string(b)) 157 | m = core.QMessage{} 158 | assert.NoError(json.NewDecoder(bytes.NewReader(b)).Decode(&m)) 159 | assert.Equal(m.Uuid, "uuid") 160 | 161 | // discard 162 | w = httptest.NewRecorder() 163 | r, _ = http.NewRequest("GET", "http://localhost/foobar", nil) 164 | r.SetBasicAuth("admin", "admin") 165 | ps = httprouter.Params{ 166 | httprouter.Param{"id", fmt.Sprintf("%d", m.Id)}, 167 | } 168 | httpcontext.Set(r, "params", ps) 169 | queueDiscardMessage(w, r) 170 | b, _ = ioutil.ReadAll(w.Body) 171 | assert.Equal(200, w.Code, string(b)) 172 | 173 | // bounce 174 | w = httptest.NewRecorder() 175 | r, _ = http.NewRequest("GET", "http://localhost/foobar", nil) 176 | r.SetBasicAuth("admin", "admin") 177 | ps = httprouter.Params{ 178 | httprouter.Param{"id", fmt.Sprintf("%d", m.Id)}, 179 | } 180 | httpcontext.Set(r, "params", ps) 181 | queueBounceMessage(w, r) 182 | b, _ = ioutil.ReadAll(w.Body) 183 | assert.Equal(200, w.Code, string(b)) 184 | } 185 | -------------------------------------------------------------------------------- /rest/handlers_users.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | "github.com/jinzhu/gorm" 8 | "github.com/julienschmidt/httprouter" 9 | "github.com/nbio/httpcontext" 10 | "github.com/toorop/tmail/api" 11 | ) 12 | 13 | // usersAdd adds an user 14 | func usersAdd(w http.ResponseWriter, r *http.Request) { 15 | if !authorized(w, r) { 16 | return 17 | } 18 | p := struct { 19 | Passwd string `json: "passwd"` 20 | AuthRelay bool `json: "authRelay"` 21 | HaveMailbox bool `json: "haveMailbox"` 22 | IsCathall bool `json: "isCatchall"` 23 | MailboxQuota string `json: "mailboxQuota"` 24 | }{} 25 | 26 | // nil body 27 | if r.Body == nil { 28 | httpWriteErrorJson(w, 422, "empty body", "") 29 | return 30 | } 31 | 32 | if err := json.NewDecoder(r.Body).Decode(&p); err != nil { 33 | httpWriteErrorJson(w, 500, "unable to get JSON body", err.Error()) 34 | return 35 | } 36 | 37 | if err := api.UserAdd(httpcontext.Get(r, "params").(httprouter.Params).ByName("user"), p.Passwd, p.MailboxQuota, p.HaveMailbox, p.AuthRelay, p.IsCathall); err != nil { 38 | httpWriteErrorJson(w, 422, "unable to create new user", err.Error()) 39 | return 40 | } 41 | logInfo(r, "user added "+httpcontext.Get(r, "params").(httprouter.Params).ByName("user")) 42 | w.Header().Set("Location", httpGetScheme()+"://"+r.Host+"/users/"+httpcontext.Get(r, "params").(httprouter.Params).ByName("user")) 43 | w.WriteHeader(201) 44 | return 45 | } 46 | 47 | // usersDel delete an user 48 | func usersDel(w http.ResponseWriter, r *http.Request) { 49 | if !authorized(w, r) { 50 | return 51 | } 52 | err := api.UserDel(httpcontext.Get(r, "params").(httprouter.Params).ByName("user")) 53 | if err == gorm.ErrRecordNotFound { 54 | httpWriteErrorJson(w, 404, "no such user "+httpcontext.Get(r, "params").(httprouter.Params).ByName("user"), err.Error()) 55 | return 56 | } 57 | if err != nil { 58 | httpWriteErrorJson(w, 500, "unable to del user "+httpcontext.Get(r, "params").(httprouter.Params).ByName("user"), err.Error()) 59 | return 60 | } 61 | 62 | } 63 | 64 | // usersGetAll return all users 65 | func usersGetAll(w http.ResponseWriter, r *http.Request) { 66 | if !authorized(w, r) { 67 | return 68 | } 69 | users, err := api.UserGetAll() 70 | if err != nil { 71 | httpWriteErrorJson(w, 500, "unable to get users", err.Error()) 72 | return 73 | } 74 | js, err := json.Marshal(users) 75 | if err != nil { 76 | httpWriteErrorJson(w, 500, "JSON encondig failed", err.Error()) 77 | return 78 | } 79 | httpWriteJson(w, js) 80 | } 81 | 82 | // usersGetOne return one user 83 | func usersGetOne(w http.ResponseWriter, r *http.Request) { 84 | if !authorized(w, r) { 85 | return 86 | } 87 | user, err := api.UserGetByLogin(httpcontext.Get(r, "params").(httprouter.Params).ByName("user")) 88 | if err == gorm.ErrRecordNotFound { 89 | httpWriteErrorJson(w, 404, "no such user "+httpcontext.Get(r, "params").(httprouter.Params).ByName("user"), "") 90 | return 91 | } 92 | if err != nil { 93 | httpWriteErrorJson(w, 500, "unable to get user "+httpcontext.Get(r, "params").(httprouter.Params).ByName("user"), err.Error()) 94 | return 95 | } 96 | js, err := json.Marshal(user) 97 | if err != nil { 98 | httpWriteErrorJson(w, 500, "unable to get user "+httpcontext.Get(r, "params").(httprouter.Params).ByName("user"), err.Error()) 99 | return 100 | } 101 | httpWriteJson(w, js) 102 | } 103 | 104 | // usersUpdate used to update user proprieties 105 | // for now tou can only change password 106 | func usersUpdate(w http.ResponseWriter, r *http.Request) { 107 | if !authorized(w, r) { 108 | return 109 | } 110 | p := struct { 111 | Passwd string `json:"passwd"` 112 | }{} 113 | 114 | // body must not be empty 115 | if r.Body == nil { 116 | httpWriteErrorJson(w, 422, "empty body", "") 117 | return 118 | } 119 | 120 | if err := json.NewDecoder(r.Body).Decode(&p); err != nil { 121 | httpWriteErrorJson(w, 500, "unable to get JSON body", err.Error()) 122 | return 123 | } 124 | 125 | if err := api.UserChangePassword(httpcontext.Get(r, "params").(httprouter.Params).ByName("user"), p.Passwd); err != nil { 126 | httpWriteErrorJson(w, 422, "unable to change user password", err.Error()) 127 | return 128 | } 129 | logInfo(r, "password changed for user "+httpcontext.Get(r, "params").(httprouter.Params).ByName("user")) 130 | w.WriteHeader(204) 131 | return 132 | } 133 | 134 | // addUsersHandlers add Users handler to router 135 | func addUsersHandlers(router *httprouter.Router) { 136 | // add user 137 | router.POST("/users/:user", wrapHandler(usersAdd)) 138 | 139 | // get all users 140 | router.GET("/users", wrapHandler(usersGetAll)) 141 | 142 | // get one user 143 | router.GET("/users/:user", wrapHandler(usersGetOne)) 144 | 145 | // del an user 146 | router.DELETE("/users/:user", wrapHandler(usersDel)) 147 | 148 | // change user password 149 | router.PUT("/users/:user", wrapHandler(usersUpdate)) 150 | } 151 | -------------------------------------------------------------------------------- /rest/logger.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | 7 | "github.com/toorop/tmail/core" 8 | ) 9 | 10 | // log helper for INFO log 11 | func logInfo(r *http.Request, msg ...string) { 12 | core.Logger.Info("http", r.RemoteAddr, "-", r.Method, r.RequestURI, "-", strings.Join(msg, " ")) 13 | } 14 | 15 | // logError is a log helper for ERROR logs 16 | func logError(r *http.Request, msg ...string) { 17 | core.Logger.Error("http", r.RemoteAddr, "-", r.Method, r.RequestURI, "-", strings.Join(msg, " ")) 18 | } 19 | 20 | // logDebug is a log helper for Debug logs 21 | func logDebug(r *http.Request, msg ...string) { 22 | core.Logger.Debug("http", r.RemoteAddr, "-", r.Method, r.RequestURI, "-", strings.Join(msg, " ")) 23 | } 24 | -------------------------------------------------------------------------------- /rest/logger_test.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "net/http" 6 | //"net/http/httptest" 7 | "io/ioutil" 8 | "testing" 9 | 10 | "github.com/toorop/tmail/config" 11 | "github.com/toorop/tmail/logger" 12 | "github.com/toorop/tmail/scope" 13 | ) 14 | 15 | func Test_log_init(t *testing.T) { 16 | var err error 17 | scope.Cfg = new(config.Config) 18 | scope.Log, err = logger.New(ioutil.Discard, false) 19 | assert.NoError(t, err) 20 | 21 | } 22 | 23 | func Test_log(t *testing.T) { 24 | r, _ := http.NewRequest("GET", "http://localhost/foobar", nil) 25 | assert.NotPanics(t, func() { logDebug(r, "foo") }) 26 | assert.NotPanics(t, func() { logInfo(r, "foo") }) 27 | assert.NotPanics(t, func() { logError(r, "foo") }) 28 | } 29 | -------------------------------------------------------------------------------- /rest/middleware_logger.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | import ( 4 | "fmt" 5 | "github.com/codegangsta/negroni" 6 | "net/http" 7 | "time" 8 | ) 9 | 10 | type Logger struct { 11 | } 12 | 13 | // NewLogger returns a new Logger instance 14 | func NewLogger() *Logger { 15 | return &Logger{} 16 | } 17 | 18 | // 19 | func (l *Logger) ServeHTTP(rw http.ResponseWriter, r *http.Request, next http.HandlerFunc) { 20 | start := time.Now() 21 | next(rw, r) 22 | res := rw.(negroni.ResponseWriter) 23 | logInfo(r, fmt.Sprintf("%v %s %v", res.Status(), http.StatusText(res.Status()), time.Since(start))) 24 | } 25 | -------------------------------------------------------------------------------- /rest/middleware_logger_test.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | import ( 4 | "bytes" 5 | "github.com/codegangsta/negroni" 6 | "github.com/stretchr/testify/assert" 7 | "io/ioutil" 8 | "net/http" 9 | "net/http/httptest" 10 | "testing" 11 | 12 | "github.com/toorop/tmail/config" 13 | "github.com/toorop/tmail/logger" 14 | "github.com/toorop/tmail/scope" 15 | ) 16 | 17 | func Test_Logger_init(t *testing.T) { 18 | var err error 19 | scope.Cfg = new(config.Config) 20 | scope.Log, err = logger.New(ioutil.Discard, false) 21 | assert.NoError(t, err) 22 | } 23 | 24 | func Test_Logger(t *testing.T) { 25 | var err error 26 | assert := assert.New(t) 27 | buff := bytes.NewBufferString("") 28 | w := httptest.NewRecorder() 29 | r, err := http.NewRequest("GET", "http://localhost/foobar", nil) 30 | assert.NoError(err) 31 | scope.Log, err = logger.New(buff, false) 32 | assert.NoError(err) 33 | 34 | l := NewLogger() 35 | n := negroni.New() 36 | // replace log for testing 37 | n.Use(l) 38 | n.UseHandler(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { 39 | rw.WriteHeader(http.StatusNotFound) 40 | })) 41 | 42 | n.ServeHTTP(w, r) 43 | assert.Equal(http.StatusNotFound, w.Code) 44 | assert.False(len(buff.Bytes()) == 0) 45 | } 46 | -------------------------------------------------------------------------------- /rest/server.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | "os" 8 | "path" 9 | "path/filepath" 10 | 11 | "github.com/codegangsta/negroni" 12 | "github.com/julienschmidt/httprouter" 13 | "github.com/nbio/httpcontext" 14 | 15 | "github.com/toorop/tmail/core" 16 | ) 17 | 18 | const ( 19 | // Max size of the posted body 20 | body_read_limit = 1048576 21 | ) 22 | 23 | // LaunchServer launches HTTP server 24 | func LaunchServer() { 25 | router := httprouter.New() 26 | router.HandlerFunc("GET", "/ping", func(w http.ResponseWriter, req *http.Request) { 27 | httpWriteJson(w, []byte(`{"msg": "pong"}`)) 28 | }) 29 | 30 | // Users handlers 31 | addUsersHandlers(router) 32 | // Queue 33 | addQueueHandlers(router) 34 | 35 | // Microservice data handler 36 | router.Handler("GET", "/msdata/:id", http.StripPrefix("/msdata/", http.FileServer(http.Dir(core.Cfg.GetTempDir())))) 37 | 38 | // Server 39 | n := negroni.New(negroni.NewRecovery(), NewLogger()) 40 | n.UseHandler(router) 41 | addr := fmt.Sprintf("%s:%d", core.Cfg.GetRestServerIp(), core.Cfg.GetRestServerPort()) 42 | 43 | // TLS 44 | if core.Cfg.GetRestServerIsTls() { 45 | core.Logger.Info("httpd " + addr + " TLS launched") 46 | log.Fatalln(http.ListenAndServeTLS(addr, path.Join(getBasePath(), "ssl/web_server.crt"), path.Join(getBasePath(), "ssl/web_server.key"), n)) 47 | } else { 48 | core.Logger.Info("httpd " + addr + " launched") 49 | log.Fatalln(http.ListenAndServe(addr, n)) 50 | } 51 | } 52 | 53 | // wrapHandler puts httprouter.Params in query context 54 | // in order to keep compatibily with http.Handler 55 | func wrapHandler(h func(http.ResponseWriter, *http.Request)) httprouter.Handle { 56 | return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { 57 | httpcontext.Set(r, "params", ps) 58 | h(w, r) 59 | } 60 | } 61 | 62 | // getBasePath is a helper for retrieving app path 63 | func getBasePath() string { 64 | p, _ := filepath.Abs(filepath.Dir(os.Args[0])) 65 | return p 66 | } 67 | -------------------------------------------------------------------------------- /rest/utils.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/toorop/tmail/core" 7 | ) 8 | 9 | // httpWriteJson send a json response 10 | func httpWriteJson(w http.ResponseWriter, out []byte) { 11 | w.Header().Set("Content-Type", "application/json; charset=UTF-8") 12 | w.Write(out) 13 | } 14 | 15 | // httpErrorJson send and json formated error 16 | func httpWriteErrorJson(w http.ResponseWriter, httpStatus int, msg, raw string) { 17 | w.Header().Set("Content-Type", "application/json; charset=UTF-8") 18 | w.WriteHeader(httpStatus) 19 | w.Write([]byte(`{"msg":"` + msg + `","raw":"` + raw + `"}`)) 20 | } 21 | 22 | // httpGetScheme returns http ou https 23 | func httpGetScheme() string { 24 | scheme := "http" 25 | if core.Cfg.GetRestServerIsTls() { 26 | scheme = "https" 27 | } 28 | return scheme 29 | } 30 | -------------------------------------------------------------------------------- /rest/utils_test.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "net/http/httptest" 6 | "testing" 7 | ) 8 | 9 | func Test_httpWriteJson(t *testing.T) { 10 | w := httptest.NewRecorder() 11 | body := `{"foo":"bar"}` 12 | httpWriteJson(w, []byte(body)) 13 | assert.Equal(t, w.Code, 200) 14 | assert.Equal(t, w.Header().Get("content-type"), "application/json; charset=UTF-8") 15 | assert.Equal(t, w.Body.String(), body) 16 | } 17 | 18 | func Test_httpWriteErrorJson(t *testing.T) { 19 | errCode := []int{404, 500} 20 | msg := "message" 21 | raw := "raw" 22 | for _, code := range errCode { 23 | w := httptest.NewRecorder() 24 | httpWriteErrorJson(w, code, msg, raw) 25 | assert.Equal(t, code, w.Code) 26 | assert.Equal(t, w.Body.String(), `{"msg":"message","raw":"raw"}`) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tmail.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io/ioutil" 7 | "log" 8 | "math/rand" 9 | "os" 10 | "os/exec" 11 | "os/signal" 12 | "path" 13 | "runtime" 14 | "syscall" 15 | "time" 16 | 17 | //"github.com/bitly/nsq/nsqd" 18 | 19 | "github.com/nsqio/nsq/nsqd" 20 | "github.com/urfave/cli" 21 | 22 | tcli "github.com/toorop/tmail/cli" 23 | "github.com/toorop/tmail/core" 24 | "github.com/toorop/tmail/rest" 25 | ) 26 | 27 | const ( 28 | // TmailVersion version of tmail 29 | TmailVersion = "0.2.0" 30 | ) 31 | 32 | func init() { 33 | runtime.GOMAXPROCS(runtime.NumCPU()) 34 | var err error 35 | if err = core.Bootstrap(); err != nil { 36 | log.Fatalln(err) 37 | } 38 | core.Version = TmailVersion 39 | 40 | // Check base path structure 41 | requiredPaths := []string{"db", "nsq", "ssl"} 42 | for _, p := range requiredPaths { 43 | if err = os.MkdirAll(path.Join(core.GetBasePath(), p), 0700); err != nil { 44 | log.Fatalln("Unable to create path "+path.Join(core.GetBasePath(), p), " - ", err.Error()) 45 | } 46 | } 47 | 48 | // TODO: if clusterMode check if nsqlookupd is available 49 | 50 | // check DB 51 | // TODO: do check in CLI call (raise error & ask for user to run tmail initdb|checkdb) 52 | if !core.IsOkDB(core.DB) { 53 | var r []byte 54 | for { 55 | fmt.Printf("Database 'driver: %s, source: %s' misses some tables.\r\nShould i create them ? (y/n):", core.Cfg.GetDbDriver(), core.Cfg.GetDbSource()) 56 | r, _, _ = bufio.NewReader(os.Stdin).ReadLine() 57 | if r[0] == 110 || r[0] == 121 { 58 | break 59 | } 60 | } 61 | if r[0] == 121 { 62 | if err = core.InitDB(core.DB); err != nil { 63 | log.Fatalln(err) 64 | } 65 | } else { 66 | log.Println("See you soon...") 67 | os.Exit(0) 68 | } 69 | } 70 | // sync tables from structs 71 | if err := core.AutoMigrateDB(core.DB); err != nil { 72 | log.Fatalln(err) 73 | } 74 | 75 | // init rand seed 76 | rand.Seed(time.Now().UTC().UnixNano()) 77 | 78 | // Dovecot support 79 | if core.Cfg.GetDovecotSupportEnabled() { 80 | _, err := exec.LookPath(core.Cfg.GetDovecotLda()) 81 | if err != nil { 82 | log.Fatalln("Unable to find Dovecot LDA binary, checks your config poarameter TMAIL_DOVECOT_LDA ", err) 83 | } 84 | } 85 | } 86 | 87 | // MAIN 88 | func main() { 89 | var err error 90 | app := cli.NewApp() 91 | app.Name = "tmail" 92 | app.Usage = "SMTP server" 93 | app.Author = "Stéphane Depierrepont aka toorop" 94 | app.Email = "toorop@tmail.io" 95 | app.Version = TmailVersion 96 | app.Commands = tcli.CliCommands 97 | // no know command ? Launch server 98 | app.Action = func(c *cli.Context) { 99 | if len(c.Args()) != 0 { 100 | cli.ShowAppHelp(c) 101 | } else { 102 | 103 | // if there is nothing to do then... do nothing 104 | if !core.Cfg.GetLaunchDeliverd() && !core.Cfg.GetLaunchSmtpd() { 105 | log.Fatalln("I have nothing to do, so i do nothing. Bye.") 106 | } 107 | 108 | // Init Bolt (used as cache) 109 | if err = core.InitBolt(); err != nil { 110 | log.Fatalln("Init bolt failed", err) 111 | } 112 | 113 | // Loop 114 | sigChan := make(chan os.Signal, 1) 115 | signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) 116 | 117 | // TODO 118 | // Chanel to comunicate between all elements 119 | //daChan := make(chan string) 120 | 121 | // init and launch nsqd 122 | opts := nsqd.NewOptions() 123 | opts.Logger = log.New(ioutil.Discard, "", 0) 124 | opts.Logger = core.NewNSQLogger() 125 | //opts.Verbose = core.Cfg.GetDebugEnabled() 126 | opts.DataPath = core.GetBasePath() + "/nsq" 127 | // if cluster get lookupd addresses 128 | if core.Cfg.GetClusterModeEnabled() { 129 | opts.NSQLookupdTCPAddresses = core.Cfg.GetNSQLookupdTcpAddresses() 130 | } 131 | 132 | // deflate (compression) 133 | opts.DeflateEnabled = true 134 | 135 | // if a message timeout it returns to the queue: https://groups.google.com/d/msg/nsq-users/xBQF1q4srUM/kX22TIoIs-QJ 136 | // msg timeout : base time to wait from consummer before requeuing a message 137 | // note: deliverd consumer return immediatly (message is handled in a go routine) 138 | // Ce qui est au dessus est faux malgres la go routine il attends toujours a la réponse 139 | // et c'est normal car le message est toujours "in flight" 140 | // En fait ce timeout c'est le temps durant lequel le message peut rester dans le state "in flight" 141 | // autrement dit c'est le temps maxi que peu prendre deliverd.processMsg 142 | opts.MsgTimeout = 10 * time.Minute 143 | 144 | // maximum duration before a message will timeout 145 | opts.MaxMsgTimeout = 15 * time.Hour 146 | 147 | // maximum requeuing timeout for a message 148 | // si le client ne demande pas de requeue dans ce delais alors 149 | // le message et considéré comme traité 150 | opts.MaxReqTimeout = 1 * time.Hour 151 | 152 | // Number of message in RAM before synching to disk 153 | opts.MemQueueSize = 0 154 | 155 | nsqd, err := nsqd.New(opts) 156 | if err != nil { 157 | log.Fatalf("ERROR: nsqd.New failed with error - %s", err.Error()) 158 | } 159 | nsqd.LoadMetadata() 160 | if err = nsqd.PersistMetadata(); err != nil { 161 | log.Fatalf("ERROR: failed to persist metadata - %s", err.Error()) 162 | } 163 | //log.Fatalln("ICI") 164 | go nsqd.Main() 165 | //log.Fatalln("LA") 166 | 167 | // smtpd 168 | //log.Fatalln("LaunchSmtpd -", core.Cfg.GetLaunchSmtpd()) 169 | if core.Cfg.GetLaunchSmtpd() { 170 | // clamav ? 171 | if core.Cfg.GetSmtpdClamavEnabled() { 172 | if err = core.NewClamav().Ping(); err != nil { 173 | log.Fatalln("Unable to connect to clamd -", err) 174 | } 175 | } 176 | smtpdDsns, err := core.GetDsnsFromString(core.Cfg.GetSmtpdDsns()) 177 | if err != nil { 178 | log.Fatalln("unable to parse smtpd dsn -", err) 179 | } 180 | for _, dsn := range smtpdDsns { 181 | go core.NewSmtpd(dsn).ListenAndServe() 182 | // TODO at this point we don't know if serveur is launched 183 | core.Logger.Info("smtpd " + dsn.String() + " launched.") 184 | } 185 | } 186 | 187 | // deliverd 188 | if core.Cfg.GetLaunchDeliverd() { 189 | go core.LaunchDeliverd() 190 | } 191 | 192 | // HTTP REST server 193 | if core.Cfg.GetRestServerLaunch() { 194 | go rest.LaunchServer() 195 | } 196 | 197 | <-sigChan 198 | core.Logger.Info("Exiting...") 199 | 200 | // close NsqQueueProducer if exists 201 | if core.Cfg.GetLaunchSmtpd() { 202 | core.NsqQueueProducer.Stop() 203 | } 204 | 205 | // flush nsqd memory to disk 206 | nsqd.Exit() 207 | 208 | // exit 209 | os.Exit(0) 210 | } 211 | } 212 | app.Run(os.Args) 213 | 214 | } 215 | --------------------------------------------------------------------------------