├── .gitignore ├── .gitmodules ├── README.md ├── go.mod ├── go.sum ├── hasmail.go ├── hasmailrc └── parts ├── connect.go └── updateTray.go /.gitignore: -------------------------------------------------------------------------------- 1 | hasmail 2 | 3 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 4 | *.o 5 | *.a 6 | *.so 7 | 8 | # Folders 9 | _obj 10 | _test 11 | 12 | # Architecture specific extensions/prefixes 13 | *.[568vq] 14 | [568vq].out 15 | 16 | *.cgo1.go 17 | *.cgo2.c 18 | _cgo_defun.c 19 | _cgo_gotypes.go 20 | _cgo_export.* 21 | 22 | _testmain.go 23 | 24 | *.exe 25 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "packages/arch-linux"] 2 | path = packages/arch-linux 3 | url = https://aur.archlinux.org/hasmail.git 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **This project has been rewritten in Rust, and is now maintained under https://github.com/jonhoo/buzz/.** 2 | 3 | --- 4 | 5 | # Introduction # 6 | 7 | Using mutt (or pine), but annoyed that it doesn't give you any notifications 8 | when you've received new emails? hasmail is a simple tray application that 9 | detects new emails on IMAP servers using IDLE (push rather than pull). When it 10 | detects unseen messages, it shows a OSD style notification and changes the tray 11 | icon to indicate that you have new mail. 12 | 13 | When hovering the mouse over the tray icon, hasmail shows which of the 14 | configured accounts have unseen messages. Clicking the icon executes a 15 | user-defined command. 16 | 17 | # Configuration # 18 | 19 | hasmail looks for `~/.hasmailrc` and expects a file in simple INI syntax. The 20 | configuration file consists of (currently) one global option and one or more 21 | accounts, each with multiple fields. 22 | 23 | ## Global fields ## 24 | 25 | - click: this command will be executed when clicking the tray icon 26 | 27 | ## Account fields ## 28 | 29 | The value in [] can be anything, and will be what is shown in the tooltip. The 30 | options for an account are as follows: 31 | 32 | - hostname: The address (+ port) to connect to. MUST currently be SSL/TLS 33 | enabled. Required. 34 | - username: Username for authentication. Required. 35 | - password: Command to execute to get password for authentication. Required. 36 | - click: An optional command to execute when clicking the tray icon if this, 37 | and only this, account has new messages. If this option is present, it 38 | overrides the global click option in the described scenario. 39 | - folder: What folder to check for new messages. INBOX is the default. 40 | - poll: How often (in minutes) to force a recheck of the account. By default it 41 | is set to 29 (as suggested in the spec) to avoid the server disconnecting us 42 | due to inactivity. 43 | 44 | # Installation # 45 | 46 | Assuming you've checked this out into `$GOPATH/src/hasmail`, just run 47 | `go install hasmail`. If your path is set up correctly, you should be able to 48 | run `hasmail` and get the message "Failed to load configuration file, 49 | exiting...". Create your config and you're ready! 50 | 51 | If you're lucky enough to be running [Arch Linux](https://www.archlinux.org/), 52 | you can just install the package from [the 53 | AUR](https://aur.archlinux.org/packages/hasmail/) or run `makepkg` in `pkg/`. 54 | 55 | # Major dependencies # 56 | 57 | - https://github.com/mattn/go-gtk/ 58 | - http://code.google.com/p/go-imap/ 59 | - https://github.com/dlintw/goconf/ 60 | 61 | # License # 62 | 63 | Use this code for whatever you want if you find it useful. You don't need to put 64 | any attribution (even though I'd be happy if you did), but please let me know if 65 | you use it for anything interesting! 66 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jonhoo/hasmail 2 | 3 | go 1.12 4 | 5 | require ( 6 | github.com/dlintw/goconf v0.0.0-20120228082610-dcc070983490 7 | github.com/mattn/go-gtk v0.0.0-20190405072524-4deadb416788 8 | github.com/mattn/go-pointer v0.0.0-20180825124634-49522c3f3791 // indirect 9 | github.com/mxk/go-imap v0.0.0-20150429134902-531c36c3f12d 10 | github.com/stretchr/testify v1.4.0 // indirect 11 | ) 12 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/dlintw/goconf v0.0.0-20120228082610-dcc070983490 h1:I8/Qu5NTaiXi1TsEYmTeLDUlf7u9pEdbG+azjDvx8Vg= 4 | github.com/dlintw/goconf v0.0.0-20120228082610-dcc070983490/go.mod h1:jWlUIP63OLr0cV2FGN2IEzSFsMAe58if8rk/SAE0JRE= 5 | github.com/mattn/go-gtk v0.0.0-20190405072524-4deadb416788 h1:y6KPjcY0SVK6Qcpyg7PQvp1x8BwxS0aZrQCNP59nDR4= 6 | github.com/mattn/go-gtk v0.0.0-20190405072524-4deadb416788/go.mod h1:PwzwfeB5syFHXORC3MtPylVcjIoTDT/9cvkKpEndGVI= 7 | github.com/mattn/go-pointer v0.0.0-20180825124634-49522c3f3791 h1:PfHMsLQJwoc0ccjK0sam6J0wQo4s8mOuAo2yQGw+T2U= 8 | github.com/mattn/go-pointer v0.0.0-20180825124634-49522c3f3791/go.mod h1:2zXcozF6qYGgmsG+SeTZz3oAbFLdD3OWqnUbNvJZAlc= 9 | github.com/mxk/go-imap v0.0.0-20150429134902-531c36c3f12d h1:+DgqA2tuWi/8VU+gVgBAa7+WZrnFbPKhQWbKBB54cVs= 10 | github.com/mxk/go-imap v0.0.0-20150429134902-531c36c3f12d/go.mod h1:xacC5qXZnL/ooiitVoe3BtI1OotFTqi5zICBs9J5Fyk= 11 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 12 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 13 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 14 | github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= 15 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 16 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 17 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 18 | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= 19 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 20 | -------------------------------------------------------------------------------- /hasmail.go: -------------------------------------------------------------------------------- 1 | // Welcome! 2 | // main() is at the bottom. 3 | 4 | package main 5 | 6 | import ( 7 | "fmt" 8 | "os" 9 | "time" 10 | 11 | "github.com/jonhoo/hasmail/parts" 12 | // for signals 13 | "os/signal" 14 | "syscall" 15 | // for configuration 16 | "github.com/dlintw/goconf" 17 | // for tray icon 18 | // https://github.com/mattn/go-gtk/blob/master/example/statusicon/statusicon.go 19 | "github.com/mattn/go-gtk/glib" 20 | "github.com/mattn/go-gtk/gtk" 21 | // for click command 22 | "os/exec" 23 | // for strings.TrimRight 24 | "strings" 25 | ) 26 | 27 | // main reads the config file, connects to each IMAP server in a separate thread 28 | // and watches the notify channel for messages telling it to update the status 29 | // icon. 30 | func main() { 31 | conf, err := goconf.ReadConfigFile(os.Getenv("HOME") + "/.hasmailrc") 32 | if err != nil { 33 | fmt.Println("Failed to load configuration file, exiting...\n", err) 34 | return 35 | } 36 | 37 | // This channel will be used to tell us if we should exit the program 38 | quit := make(chan os.Signal, 1) 39 | signal.Notify(quit, os.Interrupt) 40 | 41 | // GTK engage! 42 | gtk.Init(&os.Args) 43 | glib.SetApplicationName("hasmail") 44 | defer gtk.MainQuit() 45 | 46 | // Set up the tray icon 47 | si := gtk.NewStatusIconFromStock(gtk.STOCK_DISCONNECT) 48 | si.SetTitle("hasmail") 49 | si.SetTooltipMarkup("Not connected") 50 | 51 | // Set up the tray icon right click menu 52 | mi := gtk.NewMenuItemWithLabel("Quit") 53 | mi.Connect("activate", func() { 54 | quit <- syscall.SIGINT 55 | }) 56 | nm := gtk.NewMenu() 57 | nm.Append(mi) 58 | nm.ShowAll() 59 | si.Connect("popup-menu", func(cbx *glib.CallbackContext) { 60 | nm.Popup(nil, nil, gtk.StatusIconPositionMenu, si, uint(cbx.Args(0)), uint32(cbx.Args(1))) 61 | }) 62 | 63 | /* If the user clicks the tray icon, here's what happens: 64 | * - if only a single account has new emails, and a click command has been 65 | * specified for that account, execute it 66 | * - if the user has specified a default click handler, execute that 67 | * - otherwise, do nothing 68 | */ 69 | si.Connect("activate", func(cbx *glib.CallbackContext) { 70 | command, geterr := conf.GetString("default", "click") 71 | nonZero := 0 72 | nonZeroAccount := "" 73 | 74 | for account, unseenList := range parts.Unseen { 75 | if len(unseenList) > 0 { 76 | nonZero++ 77 | nonZeroAccount = account 78 | } 79 | } 80 | 81 | // Can't just use HasOption here because that also checks "default" section, 82 | // even though Get* doesn't... 83 | if nonZero == 1 { 84 | acommand, ageterr := conf.GetString(nonZeroAccount, "click") 85 | if ageterr == nil { 86 | command = acommand 87 | geterr = ageterr 88 | } 89 | } 90 | 91 | if geterr == nil { 92 | fmt.Printf("Executing user command: %s\n", command) 93 | shell := os.Getenv("SHELL") 94 | if shell == "" { 95 | shell = "/bin/sh" 96 | } 97 | 98 | sh := exec.Command(shell, "-c", command) 99 | sh.Env = os.Environ() 100 | err = sh.Start() 101 | if err != nil { 102 | fmt.Printf("Failed to run command '%s' on click\n", command) 103 | fmt.Println(err) 104 | } 105 | } else { 106 | fmt.Println("No action defined for click\n", geterr) 107 | } 108 | }) 109 | 110 | go gtk.Main() 111 | 112 | // Connect to all accounts 113 | sections := conf.GetSections() 114 | notify := make(chan bool, len(sections)) 115 | for _, account := range sections { 116 | // default isn't really an account 117 | if account == "default" { 118 | continue 119 | } 120 | 121 | initConnection(notify, conf, account) 122 | } 123 | 124 | // Let the user know that we've now initiated all the connections 125 | si.SetFromStock(gtk.STOCK_CONNECT) 126 | si.SetTooltipText("Connecting") 127 | 128 | // Keep updating the status icon (or quit if the user wants us to) 129 | for { 130 | select { 131 | case <-quit: 132 | return 133 | case <-notify: 134 | totUnseen := 0 135 | s := "" 136 | for account, e := range parts.Errs { 137 | if e == 0 { 138 | continue 139 | } 140 | 141 | s += account + ": " 142 | 143 | switch e { 144 | case 1: 145 | s += "Connection failed!" 146 | case 2: 147 | s += "IDLE not supported!" 148 | case 3: 149 | s += "No login credentials given!" 150 | case 4: 151 | s += "Login failed!" 152 | case 5: 153 | s += "Connection dropped!" 154 | } 155 | 156 | s += "\n" 157 | } 158 | 159 | for account, unseenList := range parts.Unseen { 160 | if parts.Errs[account] != 0 { 161 | continue 162 | } 163 | 164 | numUnseen := len(unseenList) 165 | 166 | if numUnseen >= 0 { 167 | totUnseen += numUnseen 168 | } 169 | s += account + ": " 170 | 171 | switch numUnseen { 172 | case 0: 173 | s += "No new messages" 174 | case 1: 175 | s += "One new message" 176 | default: 177 | s += fmt.Sprintf("%d new messages", numUnseen) 178 | } 179 | 180 | s += "\n" 181 | } 182 | 183 | // get rid of trailing newline 184 | s = strings.TrimRight(s, "\n") 185 | si.SetTooltipText(s) 186 | 187 | // http://developer.gnome.org/gtk3/3.0/gtk3-Stock-Items.html 188 | switch totUnseen { 189 | case 0: 190 | si.SetFromStock(gtk.STOCK_NEW) 191 | case 1: 192 | si.SetFromStock(gtk.STOCK_DND) 193 | default: 194 | si.SetFromStock(gtk.STOCK_DND_MULTIPLE) 195 | } 196 | } 197 | } 198 | } 199 | 200 | // Initializes a new connection to the given account in a separate goroutine 201 | func initConnection(notify chan bool, conf *goconf.ConfigFile, account string) { 202 | hostname, _ := conf.GetString(account, "hostname") 203 | username, _ := conf.GetString(account, "username") 204 | passexec, _ := conf.GetString(account, "password") 205 | 206 | pw := exec.Command("/bin/sh", "-c", passexec) 207 | pw.Env = os.Environ() 208 | pwbytes, err := pw.Output() 209 | if err != nil { 210 | fmt.Printf("%s: password command failed: %s\n", account, err) 211 | return 212 | } 213 | 214 | password := strings.TrimRight(string(pwbytes), "\n") 215 | if password == "" { 216 | fmt.Printf("%s: password command returned an empty string\n", account) 217 | return 218 | } 219 | 220 | folder, e := conf.GetString(account, "folder") 221 | if e != nil { 222 | folder = "INBOX" 223 | } 224 | 225 | poll, e := conf.GetInt(account, "poll") 226 | if e != nil { 227 | poll = 29 228 | } 229 | polli := time.Duration(poll) * time.Minute 230 | 231 | go parts.Connect(notify, account, hostname, username, password, folder, polli) 232 | } 233 | -------------------------------------------------------------------------------- /hasmailrc: -------------------------------------------------------------------------------- 1 | click=urxvt -e mutt 2 | 3 | [@example.com] 4 | hostname=mail.example.com:993 5 | username=me@example.com 6 | password=gnome-keyring-query get mail_pw 7 | 8 | [My other account] 9 | hostname=imap.example.net 10 | username=me@example.net 11 | password=echo mypassword 12 | click=urxvt -e sh -c "mutt -e 'source ~/.mutt/example.net.account'" 13 | 14 | [@nonstandard.com] 15 | hostname=mx.nonstandard.com 16 | username=me 17 | password=echo mypassword 18 | folder=mybox 19 | poll=10 20 | -------------------------------------------------------------------------------- /parts/connect.go: -------------------------------------------------------------------------------- 1 | package parts 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "time" 7 | 8 | "github.com/mxk/go-imap/imap" 9 | ) 10 | 11 | // errs is a map from account (as defined in the config) to an error number for 12 | // that account's connection. 0 = no error 13 | var Errs = map[string]int{} 14 | 15 | // connect sets up a new IMAPS connection to the given host using the given 16 | // credentials. The name parameter should be a human-readable name for this 17 | // mailbox. 18 | func Connect(notify chan bool, 19 | name string, 20 | address string, 21 | username string, 22 | password string, 23 | folder string, 24 | poll time.Duration) { 25 | 26 | // Connect to the server 27 | fmt.Printf("%s: connecting to server...\n", name) 28 | c, err := imap.DialTLS(address, nil) 29 | 30 | if err != nil { 31 | fmt.Printf("%s: connection failed, retrying in 3 minutes\n", name) 32 | fmt.Println(" ", err) 33 | Errs[name] = 1 34 | notify <- false 35 | time.Sleep(3 * time.Minute) 36 | go Connect(notify, name, address, username, password, folder, poll) 37 | return 38 | } 39 | 40 | // Connected successfully! 41 | Errs[name] = 0 42 | 43 | // Remember to log out and close the connection when finished 44 | defer c.Logout(30 * time.Second) 45 | 46 | // Authenticate 47 | if c.State() == imap.Login { 48 | fmt.Printf("login to %s as %s\n", address, username) 49 | _, err = c.Login(username, password) 50 | } else { 51 | fmt.Printf("%s: no login presented, exiting...\n", name) 52 | Errs[name] = 3 53 | notify <- false 54 | return 55 | } 56 | 57 | if err != nil { 58 | fmt.Printf("%s: login failed (%s), exiting...\n", name, err) 59 | Errs[name] = 4 60 | notify <- false 61 | return 62 | } 63 | 64 | // If IDLE isn't supported, we're not going to fall back on polling 65 | // Time to abandon ship! 66 | if !c.Caps["IDLE"] { 67 | fmt.Printf("%s: server does not support IMAP IDLE, exiting...\n", name) 68 | Errs[name] = 2 69 | notify <- false 70 | return 71 | } 72 | 73 | _, err = c.Select(folder, true) 74 | 75 | if err != nil { 76 | fmt.Printf("%s: could not open folder %s (%s), exiting...\n", name, folder, err) 77 | } 78 | 79 | // Get initial unread messages count 80 | fmt.Printf("%s initial state: ", name) 81 | UpdateTray(c, notify, name) 82 | 83 | // And go to IMAP IDLE mode 84 | cmd, err := c.Idle() 85 | 86 | if err != nil { 87 | // Fast reconnect 88 | fmt.Printf("%s: connection failed, reconnecting...\n", name) 89 | fmt.Println(" ", err) 90 | Errs[name] = 5 91 | go Connect(notify, name, address, username, password, folder, poll) 92 | return 93 | } 94 | 95 | // Process responses while idling 96 | for cmd.InProgress() { 97 | // Wait for server messages 98 | // Refresh every `poll` to avoid disconnection 99 | // Defaults to 29 minutes (see spec) 100 | err = c.Recv(poll) 101 | 102 | if err == io.EOF { 103 | fmt.Printf("%s: connection closed, reconnecting...\n", name) 104 | fmt.Println(" ", err) 105 | Errs[name] = 5 106 | go Connect(notify, name, address, username, password, folder, poll) 107 | return 108 | } 109 | 110 | if err != nil && err != imap.ErrTimeout { 111 | fmt.Printf("%s: error during receive: %s\n", name, err) 112 | } 113 | 114 | // We don't really care about the data, just that there *is* data 115 | cmd.Data = nil 116 | c.Data = nil 117 | 118 | // Update our view of the inbox 119 | c.IdleTerm() 120 | fmt.Printf("%s state: ", name) 121 | UpdateTray(c, notify, name) 122 | cmd, err = c.Idle() 123 | 124 | if err != nil { 125 | fmt.Printf("%s: connection failed (%s), reconnecting...\n", name, err) 126 | fmt.Println(" ", err) 127 | Errs[name] = 5 128 | go Connect(notify, name, address, username, password, folder, poll) 129 | return 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /parts/updateTray.go: -------------------------------------------------------------------------------- 1 | package parts 2 | 3 | import ( 4 | "fmt" 5 | // for parsing mail 6 | "bytes" 7 | "mime" 8 | "net/mail" 9 | // for notify-send 10 | "os/exec" 11 | // for strings.TrimRight 12 | "strings" 13 | 14 | "github.com/mxk/go-imap/imap" 15 | ) 16 | 17 | // unseen is a map from account (as defined in the config) to the UIDs of unseen 18 | // messages for that account 19 | var Unseen = map[string][]uint32{} 20 | 21 | // updateTray is called whenever a client detects that the number of unseen 22 | // messages *may* have changed. It will search the selected folder for unseen 23 | // messages, count them and store the result. Then it will use the notify 24 | // channel to let our main process update the status icon. 25 | func UpdateTray(c *imap.Client, notify chan bool, name string) { 26 | // Send > Search since Search adds CHARSET UTF-8 which might not be supported 27 | cmd, err := c.Send("SEARCH", "UNSEEN") 28 | 29 | if err != nil { 30 | fmt.Printf("%s failed to look for new messages\n", name) 31 | fmt.Println(" ", err) 32 | return 33 | } 34 | 35 | if _, ok := Unseen[name]; !ok { 36 | Unseen[name] = []uint32{} 37 | } 38 | 39 | unseenMessages := []uint32{} 40 | for cmd.InProgress() { 41 | // Wait for the next response (no timeout) 42 | err = c.Recv(-1) 43 | 44 | // Process command data 45 | for _, rsp := range cmd.Data { 46 | result := rsp.SearchResults() 47 | unseenMessages = append(unseenMessages, result...) 48 | } 49 | 50 | // Reset for next run 51 | cmd.Data = nil 52 | c.Data = nil 53 | } 54 | 55 | // Check command completion status 56 | if rsp, err := cmd.Result(imap.OK); err != nil { 57 | if err == imap.ErrAborted { 58 | fmt.Println("fetch command aborted") 59 | } else { 60 | fmt.Println("fetch error:", rsp.Info) 61 | } 62 | 63 | return 64 | } 65 | 66 | fmt.Printf("%d unseen\n", len(unseenMessages)) 67 | 68 | // Find messages that the user hasn't been notified of 69 | // TODO: Make this optional/configurable 70 | newUnseen, _ := imap.NewSeqSet("") 71 | numNewUnseen := 0 72 | for _, uid := range unseenMessages { 73 | seen := false 74 | for _, olduid := range Unseen[name] { 75 | if olduid == uid { 76 | seen = true 77 | break 78 | } 79 | } 80 | 81 | if !seen { 82 | newUnseen.AddNum(uid) 83 | numNewUnseen++ 84 | } 85 | } 86 | 87 | // If we do have new unseen messages, fetch and display them 88 | if numNewUnseen > 0 { 89 | messages := make([]string, numNewUnseen) 90 | i := 0 91 | 92 | // Fetch headers... 93 | cmd, _ = c.Fetch(newUnseen, "RFC822.HEADER") 94 | for cmd.InProgress() { 95 | c.Recv(-1) 96 | for _, rsp := range cmd.Data { 97 | header := imap.AsBytes(rsp.MessageInfo().Attrs["RFC822.HEADER"]) 98 | if msg, _ := mail.ReadMessage(bytes.NewReader(header)); msg != nil { 99 | subject := msg.Header.Get("Subject") 100 | messages[i], err = new(mime.WordDecoder).DecodeHeader(subject) 101 | if err != nil { 102 | messages[i] = subject 103 | } 104 | i++ 105 | } 106 | } 107 | 108 | cmd.Data = nil 109 | c.Data = nil 110 | } 111 | 112 | // Print them in reverse order to get newest first 113 | notification := "" 114 | for ; i > 0; i-- { 115 | notification += "> " + messages[i-1] + "\n" 116 | } 117 | notification = strings.TrimRight(notification, "\n") 118 | fmt.Println(notification) 119 | 120 | // And send them with notify-send! 121 | title := fmt.Sprintf("%s has new mail (%d unseen)", name, len(unseenMessages)) 122 | sh := exec.Command("notify-send", 123 | "-i", "notification-message-email", 124 | "-c", "email", 125 | title, notification) 126 | 127 | err := sh.Start() 128 | if err != nil { 129 | fmt.Println("Failed to notify user...") 130 | fmt.Println(err) 131 | } 132 | go func() { 133 | // collect exit code to avoid zombies 134 | sh.Wait() 135 | }() 136 | } 137 | 138 | Unseen[name] = unseenMessages 139 | 140 | // Let main process know something has changed 141 | notify <- true 142 | } 143 | --------------------------------------------------------------------------------