├── .gitignore ├── LICENSE ├── README.md ├── go.mod ├── go.sum └── nanolist.go /.gitignore: -------------------------------------------------------------------------------- 1 | nanolist 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2017 Harry Jeffery 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, this 7 | list of conditions and the following disclaimer. 8 | 9 | 2. Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | 13 | 3. Neither the name of the copyright holder nor the names of its contributors 14 | may be used to endorse or promote products derived from this software without 15 | specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 18 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 19 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 20 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 21 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 22 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 23 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 24 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 25 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 26 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | nanolist 2 | ======== 3 | 4 | nanolist is a lightweight mailing list manager written in Go. It's easy to 5 | deploy, and easy to manage. It was written as an antithesis of the experience 6 | of setting up other mailing list software. 7 | 8 | Usage 9 | ----- 10 | 11 | nanolist is controlled by emailing nanolist with a command in the subject. 12 | 13 | The following commands are available: 14 | 15 | * `help` - Reply with a list of valid commands 16 | * `lists` - Reply with a list of available mailing lists 17 | * `subscribe list-id` - Subscribe to receive mail sent to the given list 18 | * `unsubscribe list-id` - Unsubscribe from receiving mail sent to the given list 19 | 20 | Frequently Asked Questions 21 | -------------------------- 22 | 23 | ### Is there a web interface? 24 | 25 | No. If you'd like an online browsable archive of emails, I recommend looking 26 | into tools such as hypermail, which generate HTML archives from a list of 27 | emails. 28 | 29 | If you'd like to advertise the lists on your website, it's recommended to do 30 | that manually, in whatever way looks best. Subscribe buttons can be achieved 31 | with a `mailto:` link. 32 | 33 | ### How do I integrate this with my preferred mail transfer agent? 34 | 35 | I'm only familiar with postfix, for which there are instructions below. The 36 | gist of it is: have your mail server pipe emails for any mailing list addresses 37 | to `nanolist message`. nanolist will handle any messages sent to it this way, 38 | and reply using the configured SMTP server. 39 | 40 | ### Why would anyone want this? 41 | 42 | Some people prefer mailing lists for patch submission and review, some people 43 | want to play mailing-list based games such as nomic, and some people are just 44 | nostalgic. 45 | 46 | Installation 47 | ------------ 48 | 49 | First, you'll need to build and install the nanolist binary: 50 | `go get github.com/eXeC64/nanolist` 51 | 52 | Second, you'll need to write a config to either `/etc/nanolist.ini` 53 | or `/usr/local/etc/nanolist.ini` as follows: 54 | 55 | You can also specify a custom config file location by invoking nanolist 56 | with the `-config` flag: `-config=/path/to/config.ini` 57 | 58 | ```ini 59 | # File for event and error logging. nanolist does not rotate its logs 60 | # automatically. Recommended path is /var/log/mail/nanolist 61 | # You'll need to set permissions on it depending on which account your MTA 62 | # runs nanolist as. 63 | log = /path/to/logfile 64 | 65 | # An sqlite3 database is used for storing the email addresses subscribed to 66 | # each mailing list. Recommended location is /var/db/nanolist.db 67 | # You'll need to set permissions on it depending on which account your MTA 68 | # runs nanolist as. 69 | database = /path/to/sqlite/database 70 | 71 | # Address nanolist should receive user commands on 72 | command_address = lists@example.com 73 | 74 | # SMTP details for sending mail 75 | smtp_hostname = "smtp.example.com" 76 | smtp_port = 25 77 | smtp_username = "nanolist" 78 | smtp_password = "hunter2" 79 | 80 | # Create a [list.id] section for each mailing list. 81 | # The 'list.' prefix tells nanolist you're creating a mailing list. The rest 82 | # is the id of the mailing list. 83 | 84 | [list.golang] 85 | # Address this list should receieve mail on 86 | address = golang@example.com 87 | # Information to show in the list of mailing lists 88 | name = "Go programming" 89 | description = "General discussion of Go programming" 90 | # bcc all posts to the listed addresses for archival 91 | bcc = archive@example.com, datahoarder@example.com 92 | 93 | [list.announcements] 94 | address = announce@example.com 95 | name = "Announcements" 96 | description = "Important announcements" 97 | # List of email addresses that are permitted to post to this list 98 | posters = admin@example.com, moderator@example.com 99 | 100 | [list.fight-club] 101 | address = robertpaulson99@example.com 102 | # Don't tell users this list exists 103 | hidden = true 104 | # Only let subscribed users post to this list 105 | subscribers_only = true 106 | ``` 107 | 108 | Lastly, you need to hook the desired incoming addresses to nanolist: 109 | 110 | In `/etc/aliases`: 111 | ``` 112 | nanolist: "| /path/to/bin/nanolist message" 113 | ``` 114 | 115 | And run `newaliases` for the change to take effect. 116 | 117 | This creates an alias that pipes messages sent to the `nanolist` alias to the 118 | nanolist command. 119 | 120 | The final step is telling your preferred MTA to route mail to this address 121 | when needed. 122 | 123 | For postfix edit `/etc/postfix/aliases` and add: 124 | ``` 125 | lists@example.com nanolist 126 | golang@example.com nanolist 127 | announce@example.com nanolist 128 | robertpaulson99@example.com nanolist 129 | ``` 130 | and restart postfix. 131 | 132 | Congratulations, you've now set up 3 mailing lists of your own! 133 | 134 | License 135 | ------- 136 | 137 | nanolist is made available under the BSD-3-Clause license. 138 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/eXeC64/nanolist 2 | 3 | go 1.12 4 | 5 | require ( 6 | github.com/mattn/go-sqlite3 v1.10.0 7 | gopkg.in/ini.v1 v1.42.0 8 | ) 9 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/mattn/go-sqlite3 v1.10.0 h1:jbhqpg7tQe4SupckyijYiy0mJJ/pRyHvXf7JdWK860o= 2 | github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= 3 | gopkg.in/ini.v1 v1.42.0 h1:7N3gPTt50s8GuLortA00n8AqRTk75qOP98+mTPpgzRk= 4 | gopkg.in/ini.v1 v1.42.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= 5 | -------------------------------------------------------------------------------- /nanolist.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "database/sql" 7 | "flag" 8 | "fmt" 9 | _ "github.com/mattn/go-sqlite3" 10 | "gopkg.in/ini.v1" 11 | "io" 12 | "io/ioutil" 13 | "log" 14 | "net/mail" 15 | "net/smtp" 16 | "os" 17 | "strings" 18 | "time" 19 | ) 20 | 21 | type Config struct { 22 | CommandAddress string `ini:"command_address"` 23 | Log string `ini:"log"` 24 | Database string `ini:"database"` 25 | SMTPHostname string `ini:"smtp_hostname"` 26 | SMTPPort string `ini:"smtp_port"` 27 | SMTPUsername string `ini:"smtp_username"` 28 | SMTPPassword string `ini:"smtp_password"` 29 | Lists map[string]*List 30 | Debug bool 31 | ConfigFile string 32 | } 33 | 34 | type List struct { 35 | Name string `ini:"name"` 36 | Description string `ini:"description"` 37 | Id string 38 | Address string `ini:"address"` 39 | Hidden bool `ini:"hidden"` 40 | SubscribersOnly bool `ini:"subscribers_only"` 41 | Posters []string `ini:"posters,omitempty"` 42 | Bcc []string `ini:"bcc,omitempty"` 43 | } 44 | 45 | type Message struct { 46 | Subject string 47 | From string 48 | To string 49 | Cc string 50 | Bcc string 51 | Date string 52 | Id string 53 | InReplyTo string 54 | ContentType string 55 | ContentDisposition string 56 | XList string 57 | Body string 58 | MimeVersion string 59 | } 60 | 61 | var gConfig *Config 62 | 63 | // Entry point 64 | func main() { 65 | gConfig = &Config{} 66 | 67 | flag.BoolVar(&gConfig.Debug, "debug", false, "Don't send emails - print them to stdout instead") 68 | flag.StringVar(&gConfig.ConfigFile, "config", "", "Load configuration from specified file") 69 | flag.Parse() 70 | 71 | loadConfig() 72 | 73 | if len(flag.Args()) < 1 { 74 | fmt.Printf("Error: Command not specified\n") 75 | os.Exit(1) 76 | } 77 | 78 | if flag.Arg(0) == "check" { 79 | if checkConfig() { 80 | fmt.Printf("Congratulations, nanolist appears to be successfully set up!") 81 | os.Exit(0) 82 | } else { 83 | os.Exit(1) 84 | } 85 | } 86 | 87 | requireLog() 88 | 89 | if flag.Arg(0) == "message" { 90 | msg := &Message{} 91 | err := msg.FromReader(bufio.NewReader(os.Stdin)) 92 | if err != nil { 93 | log.Printf("ERROR_PARSING_MESSAGE Error=%q\n", err.Error()) 94 | os.Exit(0) 95 | } 96 | log.Printf("MESSAGE_RECEIVED Id=%q From=%q To=%q Cc=%q Bcc=%q Subject=%q\n", 97 | msg.Id, msg.From, msg.To, msg.Cc, msg.Bcc, msg.Subject) 98 | handleMessage(msg) 99 | } else { 100 | fmt.Printf("Unknown command %s\n", flag.Arg(0)) 101 | } 102 | } 103 | 104 | // Figure out if this is a command, or a mailing list post 105 | func handleMessage(msg *Message) { 106 | if isToCommandAddress(msg) { 107 | handleCommand(msg) 108 | } else { 109 | lists := lookupLists(msg) 110 | if len(lists) > 0 { 111 | for _, list := range lists { 112 | if list.CanPost(msg.From) { 113 | listMsg := msg.ResendAs(list.Id, list.Address) 114 | list.Send(listMsg) 115 | log.Printf("MESSAGE_SENT ListId=%q Id=%q From=%q To=%q Cc=%q Bcc=%q Subject=%q\n", 116 | list.Id, listMsg.Id, listMsg.From, listMsg.To, listMsg.Cc, listMsg.Bcc, listMsg.Subject) 117 | } else { 118 | handleNotAuthorisedToPost(msg) 119 | } 120 | } 121 | } else { 122 | handleNoDestination(msg) 123 | } 124 | } 125 | } 126 | 127 | // Handle the command given by the user 128 | func handleCommand(msg *Message) { 129 | if msg.Subject == "lists" { 130 | handleShowLists(msg) 131 | } else if msg.Subject == "help" { 132 | handleHelp(msg) 133 | } else if strings.HasPrefix(msg.Subject, "subscribe") { 134 | handleSubscribe(msg) 135 | } else if strings.HasPrefix(msg.Subject, "unsubscribe") { 136 | handleUnsubscribe(msg) 137 | } else { 138 | handleUnknownCommand(msg) 139 | } 140 | } 141 | 142 | // Reply to a message that has nowhere to go 143 | func handleNoDestination(msg *Message) { 144 | reply := msg.Reply() 145 | reply.From = gConfig.CommandAddress 146 | reply.Body = "No mailing lists addressed. Your message has not been delivered.\r\n" 147 | reply.Send([]string{msg.From}) 148 | log.Printf("UNKNOWN_DESTINATION From=%q To=%q Cc=%q Bcc=%q", msg.From, msg.To, msg.Cc, msg.Bcc) 149 | } 150 | 151 | // Reply that the user isn't authorised to post to the list 152 | func handleNotAuthorisedToPost(msg *Message) { 153 | reply := msg.Reply() 154 | reply.From = gConfig.CommandAddress 155 | reply.Body = "You are not an approved poster for this mailing list. Your message has not been delivered.\r\n" 156 | reply.Send([]string{msg.From}) 157 | log.Printf("UNAUTHORISED_POST From=%q To=%q Cc=%q Bcc=%q", msg.From, msg.To, msg.Cc, msg.Bcc) 158 | } 159 | 160 | // Reply to an unknown command, giving some help 161 | func handleUnknownCommand(msg *Message) { 162 | reply := msg.Reply() 163 | reply.From = gConfig.CommandAddress 164 | reply.Body = fmt.Sprintf( 165 | "%s is not a valid command.\r\n\r\n"+ 166 | "Valid commands are:\r\n\r\n"+ 167 | commandInfo(), 168 | msg.Subject) 169 | reply.Send([]string{msg.From}) 170 | log.Printf("UNKNOWN_COMMAND From=%q", msg.From) 171 | } 172 | 173 | // Reply to a help command with help information 174 | func handleHelp(msg *Message) { 175 | var body bytes.Buffer 176 | fmt.Fprintf(&body, commandInfo()) 177 | reply := msg.Reply() 178 | reply.From = gConfig.CommandAddress 179 | reply.Body = body.String() 180 | reply.Send([]string{msg.From}) 181 | log.Printf("HELP_SENT To=%q", reply.To) 182 | } 183 | 184 | // Reply to a show mailing lists command with a list of mailing lists 185 | func handleShowLists(msg *Message) { 186 | var body bytes.Buffer 187 | fmt.Fprintf(&body, "Available mailing lists:\r\n\r\n") 188 | for _, list := range gConfig.Lists { 189 | if !list.Hidden { 190 | fmt.Fprintf(&body, 191 | "Id: %s\r\n"+ 192 | "Name: %s\r\n"+ 193 | "Description: %s\r\n"+ 194 | "Address: %s\r\n\r\n", 195 | list.Id, list.Name, list.Description, list.Address) 196 | } 197 | } 198 | 199 | fmt.Fprintf(&body, 200 | "\r\nTo subscribe to a mailing list, email %s with 'subscribe ' as the subject.\r\n", 201 | gConfig.CommandAddress) 202 | 203 | reply := msg.Reply() 204 | reply.From = gConfig.CommandAddress 205 | reply.Body = body.String() 206 | reply.Send([]string{msg.From}) 207 | log.Printf("LIST_SENT To=%q", reply.To) 208 | } 209 | 210 | // Handle a subscribe command 211 | func handleSubscribe(msg *Message) { 212 | listId := strings.TrimPrefix(msg.Subject, "subscribe ") 213 | list := lookupList(listId) 214 | 215 | if list == nil { 216 | reply := msg.Reply() 217 | reply.Body = fmt.Sprintf("Unable to subscribe to %s - it is not a valid mailing list.\r\n", listId) 218 | reply.Send([]string{msg.From}) 219 | log.Printf("INVALID_SUBSCRIPTION_REQUEST User=%q List=%q\n", msg.From, listId) 220 | os.Exit(0) 221 | } 222 | 223 | // Switch to id - in case we were passed address 224 | listId = list.Id 225 | 226 | if isSubscribed(msg.From, listId) { 227 | reply := msg.Reply() 228 | reply.Body = fmt.Sprintf("You are already subscribed to %s\r\n", listId) 229 | reply.Send([]string{msg.From}) 230 | log.Printf("DUPLICATE_SUBSCRIPTION_REQUEST User=%q List=%q\n", msg.From, listId) 231 | os.Exit(0) 232 | } 233 | 234 | addSubscription(msg.From, listId) 235 | reply := msg.Reply() 236 | reply.Body = fmt.Sprintf("You are now subscribed to %s\r\n", listId) 237 | reply.Send([]string{msg.From}) 238 | } 239 | 240 | // Handle an unsubscribe command 241 | func handleUnsubscribe(msg *Message) { 242 | listId := strings.TrimPrefix(msg.Subject, "unsubscribe ") 243 | list := lookupList(listId) 244 | 245 | if list == nil { 246 | reply := msg.Reply() 247 | reply.Body = fmt.Sprintf("Unable to unsubscribe from %s - it is not a valid mailing list.\r\n", listId) 248 | reply.Send([]string{msg.From}) 249 | log.Printf("INVALID_UNSUBSCRIPTION_REQUEST User=%q List=%q\n", msg.From, listId) 250 | os.Exit(0) 251 | } 252 | 253 | // Switch to id - in case we were passed address 254 | listId = list.Id 255 | 256 | if !isSubscribed(msg.From, listId) { 257 | reply := msg.Reply() 258 | reply.Body = fmt.Sprintf("You aren't subscribed to %s\r\n", listId) 259 | reply.Send([]string{msg.From}) 260 | log.Printf("DUPLICATE_UNSUBSCRIPTION_REQUEST User=%q List=%q\n", msg.From, listId) 261 | os.Exit(0) 262 | } 263 | 264 | removeSubscription(msg.From, listId) 265 | reply := msg.Reply() 266 | reply.Body = fmt.Sprintf("You are now unsubscribed from %s\r\n", listId) 267 | reply.Send([]string{msg.From}) 268 | } 269 | 270 | // MESSAGE LOGIC ////////////////////////////////////////////////////////////// 271 | 272 | // Read a message from the given io.Reader 273 | func (msg *Message) FromReader(stream io.Reader) error { 274 | inMessage, err := mail.ReadMessage(stream) 275 | if err != nil { 276 | return err 277 | } 278 | 279 | body, err := ioutil.ReadAll(inMessage.Body) 280 | if err != nil { 281 | return err 282 | } 283 | 284 | msg.MimeVersion = inMessage.Header.Get("MIME-Version") 285 | msg.Subject = inMessage.Header.Get("Subject") 286 | msg.From = inMessage.Header.Get("From") 287 | msg.Id = inMessage.Header.Get("Message-ID") 288 | msg.InReplyTo = inMessage.Header.Get("In-Reply-To") 289 | msg.Body = string(body[:]) 290 | msg.To = inMessage.Header.Get("To") 291 | msg.Cc = inMessage.Header.Get("Cc") 292 | msg.Bcc = inMessage.Header.Get("Bcc") 293 | msg.Date = inMessage.Header.Get("Date") 294 | msg.ContentType = inMessage.Header.Get("Content-Type") 295 | msg.ContentDisposition = inMessage.Header.Get("Content-Disposition") 296 | 297 | return nil 298 | } 299 | 300 | // Create a new message that replies to this message 301 | func (msg *Message) Reply() *Message { 302 | reply := &Message{} 303 | reply.Subject = "Re: " + msg.Subject 304 | reply.To = msg.From 305 | reply.InReplyTo = msg.Id 306 | reply.Date = time.Now().Format("Mon, 2 Jan 2006 15:04:05 -0700") 307 | return reply 308 | } 309 | 310 | // Prepare a copy of the message that we're forwarding to a list 311 | func (msg *Message) ResendAs(listId string, listAddress string) *Message { 312 | send := &Message{} 313 | send.Subject = msg.Subject 314 | send.From = msg.From 315 | send.To = msg.To 316 | send.Cc = msg.Cc 317 | send.Date = msg.Date 318 | send.Id = msg.Id 319 | send.InReplyTo = msg.InReplyTo 320 | send.XList = listId + " <" + listAddress + ">" 321 | send.Body = msg.Body 322 | send.ContentType = msg.ContentType 323 | send.ContentDisposition = msg.ContentDisposition 324 | send.MimeVersion = msg.MimeVersion 325 | 326 | // If the destination mailing list is in the Bcc field, keep it there 327 | bccList, err := mail.ParseAddressList(msg.Bcc) 328 | if err == nil { 329 | for _, bcc := range bccList { 330 | if bcc.Address == listAddress { 331 | send.Bcc = listId + " <" + listAddress + ">" 332 | break 333 | } 334 | } 335 | } 336 | return send 337 | } 338 | 339 | // Generate a emailable represenation of this message 340 | func (msg *Message) String() string { 341 | var buf bytes.Buffer 342 | 343 | fmt.Fprintf(&buf, "From: %s\r\n", msg.From) 344 | fmt.Fprintf(&buf, "To: %s\r\n", msg.To) 345 | fmt.Fprintf(&buf, "Cc: %s\r\n", msg.Cc) 346 | fmt.Fprintf(&buf, "Bcc: %s\r\n", msg.Bcc) 347 | if len(msg.Date) > 0 { 348 | fmt.Fprintf(&buf, "Date: %s\r\n", msg.Date) 349 | } 350 | if len(msg.Id) > 0 { 351 | fmt.Fprintf(&buf, "Messsage-ID: %s\r\n", msg.Id) 352 | } 353 | fmt.Fprintf(&buf, "In-Reply-To: %s\r\n", msg.InReplyTo) 354 | if len(msg.XList) > 0 { 355 | fmt.Fprintf(&buf, "X-Mailing-List: %s\r\n", msg.XList) 356 | fmt.Fprintf(&buf, "List-ID: %s\r\n", msg.XList) 357 | fmt.Fprintf(&buf, "Sender: %s\r\n", msg.XList) 358 | } 359 | if len(msg.ContentType) > 0 { 360 | fmt.Fprintf(&buf, "Content-Type: %s\r\n", msg.ContentType) 361 | } 362 | if len(msg.ContentDisposition) > 0 { 363 | fmt.Fprintf(&buf, "Content-Disposition: %s\r\n", msg.ContentDisposition) 364 | } 365 | if len(msg.MimeVersion) > 0 { 366 | fmt.Fprintf(&buf, "MIME-Version: %s\r\n", msg.MimeVersion) 367 | } 368 | 369 | fmt.Fprintf(&buf, "Subject: %s\r\n", msg.Subject) 370 | fmt.Fprintf(&buf, "\r\n%s", msg.Body) 371 | 372 | return buf.String() 373 | } 374 | 375 | func (msg *Message) Send(recipients []string) { 376 | 377 | e, err_parse := mail.ParseAddress(msg.From) 378 | if err_parse != nil { 379 | log.Printf("ERROR_PARSING Error=%s\n", err_parse) 380 | os.Exit(0) 381 | } 382 | 383 | if gConfig.Debug { 384 | fmt.Printf("------------------------------------------------------------\n") 385 | fmt.Printf("SENDING MESSAGE TO:\n") 386 | for _, r := range recipients { 387 | fmt.Printf(" - %s\n", r) 388 | } 389 | fmt.Printf("MESSAGE:\n") 390 | fmt.Printf("%s\n", msg.String()) 391 | return 392 | } 393 | 394 | auth := smtp.PlainAuth("", gConfig.SMTPUsername, gConfig.SMTPPassword, gConfig.SMTPHostname) 395 | err := smtp.SendMail(gConfig.SMTPHostname+":"+gConfig.SMTPPort, auth, e.Address, recipients, []byte(msg.String())) 396 | if err != nil { 397 | log.Printf("ERROR_SENDING Error=%q\n", err.Error()) 398 | os.Exit(0) 399 | } 400 | } 401 | 402 | // MAILING LIST LOGIC ///////////////////////////////////////////////////////// 403 | 404 | // Check if the user is authorised to post to this mailing list 405 | func (list *List) CanPost(from string) bool { 406 | 407 | // Is this list restricted to subscribers only? 408 | if list.SubscribersOnly && !isSubscribed(from, list.Id) { 409 | return false 410 | } 411 | 412 | // Is there a whitelist of approved posters? 413 | if len(list.Posters) > 0 { 414 | for _, poster := range list.Posters { 415 | if from == poster { 416 | return true 417 | } 418 | } 419 | return false 420 | } 421 | 422 | return true 423 | } 424 | 425 | // Send a message to the mailing list 426 | func (list *List) Send(msg *Message) { 427 | recipients := fetchSubscribers(list.Id) 428 | for _, bcc := range list.Bcc { 429 | recipients = append(recipients, bcc) 430 | } 431 | msg.Send(recipients) 432 | } 433 | 434 | // DATABASE LOGIC ///////////////////////////////////////////////////////////// 435 | 436 | // Open the database 437 | func openDB() (*sql.DB, error) { 438 | db, err := sql.Open("sqlite3", gConfig.Database) 439 | 440 | if err != nil { 441 | return nil, err 442 | } 443 | 444 | _, err = db.Exec(` 445 | CREATE TABLE IF NOT EXISTS "subscriptions" ( 446 | "list" TEXT, 447 | "user" TEXT 448 | ); 449 | `) 450 | 451 | return db, err 452 | } 453 | 454 | // Open the database or fail immediately 455 | func requireDB() *sql.DB { 456 | db, err := openDB() 457 | if err != nil { 458 | log.Printf("DATABASE_ERROR Error=%q\n", err.Error()) 459 | os.Exit(1) 460 | } 461 | return db 462 | } 463 | 464 | // Fetch list of subscribers to a mailing list from database 465 | func fetchSubscribers(listId string) []string { 466 | db := requireDB() 467 | rows, err := db.Query("SELECT user FROM subscriptions WHERE list=?", listId) 468 | 469 | if err != nil { 470 | log.Printf("DATABASE_ERROR Error=%q\n", err.Error()) 471 | os.Exit(0) 472 | } 473 | 474 | listIds := []string{} 475 | defer rows.Close() 476 | for rows.Next() { 477 | var user string 478 | rows.Scan(&user) 479 | listIds = append(listIds, user) 480 | } 481 | 482 | return listIds 483 | } 484 | 485 | // Check if a user is subscribed to a mailing list 486 | func isSubscribed(user string, list string) bool { 487 | addressObj, err := mail.ParseAddress(user) 488 | if err != nil { 489 | log.Printf("DATABASE_ERROR Error=%q\n", err.Error()) 490 | os.Exit(0) 491 | } 492 | db := requireDB() 493 | 494 | exists := false 495 | err = db.QueryRow("SELECT 1 FROM subscriptions WHERE user=? AND list=?", addressObj.Address, list).Scan(&exists) 496 | 497 | if err == sql.ErrNoRows { 498 | return false 499 | } else if err != nil { 500 | log.Printf("DATABASE_ERROR Error=%q\n", err.Error()) 501 | os.Exit(0) 502 | } 503 | 504 | return true 505 | } 506 | 507 | // Add a subscription to the subscription database 508 | func addSubscription(user string, list string) { 509 | addressObj, err := mail.ParseAddress(user) 510 | if err != nil { 511 | log.Printf("DATABASE_ERROR Error=%q\n", err.Error()) 512 | os.Exit(0) 513 | } 514 | 515 | db := requireDB() 516 | _, err = db.Exec("INSERT INTO subscriptions (user,list) VALUES(?,?)", addressObj.Address, list) 517 | if err != nil { 518 | log.Printf("DATABASE_ERROR Error=%q\n", err.Error()) 519 | os.Exit(0) 520 | } 521 | log.Printf("SUBSCRIPTION_ADDED User=%q List=%q\n", user, list) 522 | } 523 | 524 | // Remove a subscription from the subscription database 525 | func removeSubscription(user string, list string) { 526 | addressObj, err := mail.ParseAddress(user) 527 | if err != nil { 528 | log.Printf("DATABASE_ERROR Error=%q\n", err.Error()) 529 | os.Exit(0) 530 | } 531 | 532 | db := requireDB() 533 | _, err = db.Exec("DELETE FROM subscriptions WHERE user=? AND list=?", addressObj.Address, list) 534 | if err != nil { 535 | log.Printf("DATABASE_ERROR Error=%q\n", err.Error()) 536 | os.Exit(0) 537 | } 538 | log.Printf("SUBSCRIPTION_REMOVED User=%q List=%q\n", user, list) 539 | } 540 | 541 | // Remove all subscriptions from a given mailing list 542 | func clearSubscriptions(list string) { 543 | db := requireDB() 544 | _, err := db.Exec("DELETE FROM subscriptions WHERE AND list=?", list) 545 | if err != nil { 546 | log.Printf("DATABASE_ERROR Error=%q\n", err.Error()) 547 | os.Exit(0) 548 | } 549 | } 550 | 551 | // HELPER FUNCTIONS /////////////////////////////////////////////////////////// 552 | 553 | // Open the log file for logging 554 | func openLog() error { 555 | logFile, err := os.OpenFile(gConfig.Log, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0644) 556 | if err != nil { 557 | return err 558 | } 559 | out := io.MultiWriter(logFile, os.Stderr) 560 | log.SetOutput(out) 561 | return nil 562 | } 563 | 564 | // Open the log, or fail immediately 565 | func requireLog() { 566 | err := openLog() 567 | if err != nil { 568 | log.Printf("LOG_ERROR Error=%q\n", err.Error()) 569 | os.Exit(0) 570 | } 571 | } 572 | 573 | // Load gConfig from the on-disk config file 574 | func loadConfig() { 575 | var ( 576 | err error 577 | cfg *ini.File 578 | ) 579 | 580 | if len(gConfig.ConfigFile) > 0 { 581 | cfg, err = ini.Load(gConfig.ConfigFile) 582 | } else { 583 | cfg, err = ini.LooseLoad("nanolist.ini", "/usr/local/etc/nanolist.ini", "/etc/nanolist.ini") 584 | } 585 | 586 | if err != nil { 587 | log.Printf("CONFIG_ERROR Error=%q\n", err.Error()) 588 | os.Exit(0) 589 | } 590 | 591 | err = cfg.Section("").MapTo(gConfig) 592 | if err != nil { 593 | log.Printf("CONFIG_ERROR Error=%q\n", err.Error()) 594 | os.Exit(0) 595 | } 596 | 597 | gConfig.Lists = make(map[string]*List) 598 | 599 | for _, section := range cfg.ChildSections("list") { 600 | list := &List{} 601 | err = section.MapTo(list) 602 | if err != nil { 603 | log.Printf("CONFIG_ERROR Error=%q\n", err.Error()) 604 | os.Exit(0) 605 | } 606 | list.Id = strings.TrimPrefix(section.Name(), "list.") 607 | gConfig.Lists[list.Address] = list 608 | } 609 | } 610 | 611 | // Retrieve a list of mailing lists that are recipients of the given message 612 | func lookupLists(msg *Message) []*List { 613 | lists := []*List{} 614 | 615 | toList, err := mail.ParseAddressList(msg.To) 616 | if err == nil { 617 | for _, to := range toList { 618 | list := lookupList(to.Address) 619 | if list != nil { 620 | lists = append(lists, list) 621 | } 622 | } 623 | } 624 | 625 | ccList, err := mail.ParseAddressList(msg.Cc) 626 | if err == nil { 627 | for _, cc := range ccList { 628 | list := lookupList(cc.Address) 629 | if list != nil { 630 | lists = append(lists, list) 631 | } 632 | } 633 | } 634 | 635 | bccList, err := mail.ParseAddressList(msg.Bcc) 636 | if err == nil { 637 | for _, bcc := range bccList { 638 | list := lookupList(bcc.Address) 639 | if list != nil { 640 | lists = append(lists, list) 641 | } 642 | } 643 | } 644 | 645 | return lists 646 | } 647 | 648 | // Look up a mailing list by id or address 649 | func lookupList(listKey string) *List { 650 | for _, list := range gConfig.Lists { 651 | if listKey == list.Id || listKey == list.Address { 652 | return list 653 | } 654 | } 655 | return nil 656 | } 657 | 658 | // Is the message bound for our command address? 659 | func isToCommandAddress(msg *Message) bool { 660 | toList, err := mail.ParseAddressList(msg.To) 661 | if err == nil { 662 | for _, to := range toList { 663 | if to.Address == gConfig.CommandAddress { 664 | return true 665 | } 666 | } 667 | } 668 | 669 | ccList, err := mail.ParseAddressList(msg.Cc) 670 | if err == nil { 671 | for _, cc := range ccList { 672 | if cc.Address == gConfig.CommandAddress { 673 | return true 674 | } 675 | } 676 | } 677 | 678 | bccList, err := mail.ParseAddressList(msg.Bcc) 679 | if err == nil { 680 | for _, bcc := range bccList { 681 | if bcc.Address == gConfig.CommandAddress { 682 | return true 683 | } 684 | } 685 | } 686 | 687 | return false 688 | } 689 | 690 | // Generate an email-able list of commands 691 | func commandInfo() string { 692 | return fmt.Sprintf(" help\r\n"+ 693 | " Information about valid commands\r\n"+ 694 | "\r\n"+ 695 | " list\r\n"+ 696 | " Retrieve a list of available mailing lists\r\n"+ 697 | "\r\n"+ 698 | " subscribe \r\n"+ 699 | " Subscribe to \r\n"+ 700 | "\r\n"+ 701 | " unsubscribe \r\n"+ 702 | " Unsubscribe from \r\n"+ 703 | "\r\n"+ 704 | "To send a command, email %s with the command as the subject.\r\n", 705 | gConfig.CommandAddress) 706 | } 707 | 708 | // Check for a valid configuration 709 | func checkConfig() bool { 710 | _, err := openDB() 711 | if err != nil { 712 | fmt.Printf("There's a problem with the database: %s\n", err.Error()) 713 | return false 714 | } 715 | 716 | err = openLog() 717 | if err != nil { 718 | fmt.Printf("There's a problem with the log: %s\n", err.Error()) 719 | return false 720 | } 721 | 722 | client, err := smtp.Dial(gConfig.SMTPHostname + ":" + gConfig.SMTPPort) 723 | if err != nil { 724 | fmt.Printf("There's a problem connecting to your SMTP server: %s\n", err.Error()) 725 | return false 726 | } 727 | 728 | auth := smtp.PlainAuth("", gConfig.SMTPUsername, gConfig.SMTPPassword, gConfig.SMTPHostname) 729 | err = client.Auth(auth) 730 | if err != nil { 731 | fmt.Printf("There's a problem authenticating with your SMTP server: %s\n", err.Error()) 732 | return false 733 | } 734 | 735 | return true 736 | } 737 | --------------------------------------------------------------------------------