├── .gitignore ├── .idea ├── codeStyles │ └── codeStyleConfig.xml ├── dovecot-xaps-daemon.iml ├── encodings.xml ├── inspectionProfiles │ └── Project_Default.xml ├── modules.xml └── vcs.xml ├── .travis.yml ├── LICENSE ├── README.md ├── aps └── apns.go ├── database ├── database.go ├── database_test.go └── testdata │ └── database.json ├── etc ├── systemd │ └── xapsd.service └── xapsd │ └── xapsd.conf ├── go.mod ├── go.sum ├── logger └── logger.go ├── socket ├── socket.go └── socket_test.go └── xapsd.go /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | ### Go template 26 | # Binaries for programs and plugins 27 | *.exe 28 | *.dll 29 | *.so 30 | *.dylib 31 | 32 | # Test binary, build with `go test -c` 33 | *.test 34 | 35 | # Output of the go coverage tool, specifically when used with LiteIDE 36 | *.out 37 | 38 | # Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736 39 | .glide/ 40 | ### JetBrains template 41 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 42 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 43 | 44 | # User-specific stuff: 45 | .idea/**/workspace.xml 46 | .idea/**/tasks.xml 47 | .idea/dictionaries 48 | 49 | # Sensitive or high-churn files: 50 | .idea/**/dataSources/ 51 | .idea/**/dataSources.ids 52 | .idea/**/dataSources.xml 53 | .idea/**/dataSources.local.xml 54 | .idea/**/sqlDataSources.xml 55 | .idea/**/dynamic.xml 56 | .idea/**/uiDesigner.xml 57 | 58 | # Gradle: 59 | .idea/**/gradle.xml 60 | .idea/**/libraries 61 | 62 | # CMake 63 | cmake-build-debug/ 64 | 65 | # Mongo Explorer plugin: 66 | .idea/**/mongoSettings.xml 67 | 68 | ## File-based project format: 69 | *.iws 70 | 71 | ## Plugin-specific files: 72 | 73 | # IntelliJ 74 | out/ 75 | 76 | # mpeltonen/sbt-idea plugin 77 | .idea_modules/ 78 | 79 | # JIRA plugin 80 | atlassian-ide-plugin.xml 81 | 82 | # Cursive Clojure plugin 83 | .idea/replstate.xml 84 | 85 | # Crashlytics plugin (for Android Studio and IntelliJ) 86 | com_crashlytics_export_strings.xml 87 | crashlytics.properties 88 | crashlytics-build.properties 89 | fabric.properties 90 | 91 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/dovecot-xaps-daemon.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/encodings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 22 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | install: 4 | 5 | script: go test -v ./... -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Stefan Arentz 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/st3fan/dovecot-xaps-daemon.svg)](https://travis-ci.org/st3fan/dovecot-xaps-daemon) 2 | 3 | iOS Push Email for Dovecot 4 | ========================== 5 | 6 | What is this? 7 | ------------- 8 | 9 | This project, together with the [dovecot-xaps-plugin](https://github.com/st3fan/dovecot-xaps-plugin) project, will enable push email for iOS devices that talk to your Dovecot 2.0.x IMAP server. This is specially useful for people who are migrating away from running email services on OS X Server and want to keep the Push Email ability. 10 | 11 | > Please note that it is not possible to use this project without legally owning a copy of OS X Server. You can purchase OS X Server on the [Mac App Store](https://itunes.apple.com/ca/app/os-x-server/id714547929?mt=12) or download it for free if you are a registered Mac or iOS developer. 12 | 13 | What is the advantage of push? 14 | ------------------------------ 15 | 16 | Without Push, your iPhone will Fetch email periodically. This means that mail is delayed and that your device will use precious power to connect to a remote IMAP server. 17 | 18 | Using native Push messages means that your phone will receive new email notifications over a highly optimized connection to iCloud that it already has for other purposes. 19 | 20 | High Level Overview 21 | ------------------- 22 | 23 | There are two parts to enabling iOS Push Email. You will need both parts for this to work. 24 | 25 | First you need to install the Dovecot plugins from the [dovecot-xaps-plugin](https://github.com/st3fan/dovecot-xaps-plugin) project. How do to that is documented in the README file in that project. The Dovecot plugin adds support for the `XAPPLEPUSHSERVICE` IMAP extension that will let iOS devices register themselves to receive native push notifications for new email arrival. 26 | 27 | (Apple did not document this feature, but it did publish the source code for all their Dovecot patches on the [Apple Open Source project site](http://www.opensource.apple.com/source/dovecot/dovecot-293/), which include this feature. Although I was not able to follow a specification, I was able to read their open source project and do a clean implementation with all original code.) 28 | 29 | Second, you need to install a daemon process (contained in this project) that will be responsible for receiving new email notifications from the Dovecot Local Delivery Agent and transforming those into native Apple Push Notifications. 30 | 31 | Installation 32 | ============ 33 | 34 | Prerequisites 35 | ------------- 36 | 37 | You are going to need the following things to get this going: 38 | 39 | * Some patience and willingness to experiment - Although I run this project in production, it is still a very early version and it may contain bugs. 40 | * Because you will need a certificate to talk to the Apple Push Notifications Service, you can only run this software if you are migrating away from an existing OS X Server setup where you had Push Email enabled. 41 | * Dovecot > 2.2.19 (which introduced the push-notification plugin) 42 | 43 | Exporting and converting the certificate 44 | ---------------------------------------- 45 | 46 | First you have to export the certificate that is stored on your OS X 47 | Server. Do this by opening Keychain.app and select the System keychain and the Certificates category. Locate the right certificate by expanding those whose name start with *APSP:* and then look for the certificate with a private key that is named `com.apple.servermgrd.apns.mail`. 48 | 49 | Now export that certficate by selecting it and then choose *Export Items* from the *File* menu. You want to store the certificate as PushEmail on your Desktop as a *Personal Information Exchange (.p12)* file. You will be asked to secure this exported certificate with a password. This is a new password and not your login password. 50 | 51 | Then, start *Terminal.app* and execute the following commands: 52 | 53 | ``` 54 | cd ~/Desktop 55 | openssl pkcs12 -in PushEmail.p12 -nocerts -nodes -out key.pem 56 | openssl pkcs12 -in PushEmail.p12 -clcerts -nokeys -out certificate.pem 57 | ``` 58 | 59 | You will be asked for a password, which should be the same password that you entered when you exported the certificate. 60 | 61 | You can test if the certificate and key are correct by making a connection to the apple push notifications gateway: 62 | 63 | ``` 64 | openssl s_client -connect gateway.push.apple.com:2195 -cert certificate.pem -key key.pem 65 | ``` 66 | 67 | The connection may close but check if you see something like `Verify return code: 0 (ok)` appear. 68 | 69 | If the connection fails and outputs `Verify return code: 20 (unable to get local issuer certificate)` the chain of trust might be broken. Download the root certificate entrust_2048_ca.cer from [Entrust] (https://www.entrust.net/downloads/root_index.cfm?) and issue the command appending -CAfile: 70 | 71 | ``` 72 | openssl s_client -connect gateway.push.apple.com:2195 -cert certificate.pem -key key.pem -CAfile entrust_2048_ca.cer 73 | ``` 74 | 75 | > TODO: Does this mean we also need to pass the CA file to the `xapsd` process? 76 | 77 | You now have your exported certificate and private key stored in two separate PEM encoded files that can be used by the xapsd daemon. 78 | 79 | Copy these two files to your Dovecot server. 80 | 81 | > Note that the APNS certificates expire 1 year after they were originally issued by Apple, so they will need to be renewed or regenerated through the OS X Server application each year. Expiration information for these certificates can be found at the [Apple Push Certificates Portal](https://identity.apple.com/pushcert/). 82 | 83 | Compiling and Installing the Daemon 84 | ----------------------------------- 85 | 86 | The daemon is written in Go. The easiest way to build it is with go itself. 87 | 88 | ``` 89 | git clone https://github.com/st3fan/dovecot-xaps-daemon.git 90 | cd dovecot-xaps-daemon 91 | go build -o xapsd 92 | ``` 93 | 94 | Running the Daemon 95 | ------------------ 96 | 97 | Because this code is work in progress, it currently is not packaged properly as a good behaving background process. I recommend following the instructions below in a `screen` or `tmux` session so that it is easy to keep the daemon running. The next release will have better support for running this as a background service. 98 | 99 | You can run the daemon as follows: 100 | 101 | ``` 102 | bin/xapsd -key=$HOME/key.pem -certificate=$HOME/certificate.pem \ 103 | -database=$HOME/xapsd.json -socket=/var/run/xapsd/xapsd.sock \ 104 | -delayCheckInterval=20 -delayTime=30 105 | ``` 106 | 107 | This assumes that you have the exported `certificate.pem` and `key.pem` files in your home directory. The database file will be created by the daemon. It will contain the mappings between the IMAP users, their mail accounts and the iOS devices. It is a simple JSON file so you can look at it manually by opening it in a text editor. 108 | 109 | The daemon is verbose and should print out a bunch of informational messages. If you see errors, please [file a bug](https://github.com/st3fan/dovecot-xaps-daemon/issues/new). 110 | 111 | 112 | Setting up Devices 113 | ------------------ 114 | 115 | Your iOS devices will discover that the server supports Push automatically the first time they connect. To force them to reconnect you can reboot the iOS device or turn Airport Mode on and off with a little delay in between. 116 | 117 | If you go to your Email settings, you should see that the account has switched to Push. 118 | 119 | Privacy 120 | ------- 121 | 122 | Each time a message is received, dovecot-xaps-daemon sends Apple a TLS-secured HTTP request, which Apple uses to send a notification over a persistent connection maintained to between the user's device and Apple's push notification servers. 123 | 124 | The request contains the following information: a device token (used by Apple to identify which device should be sent a push notification), an account ID (used by the user's device to identify which account it should poll for new messages), and a certificate topic. The certificate topic identifies the server to Apple and is hardcoded in the certificate issued by Apple and setup in the configuration for dovecot-xaps-daemon. 125 | 126 | By virtue of having made the request, Apple also learns the IP address of the server sending the push notification, and the time at which the push notification is sent by the server to Apple. 127 | 128 | While no information typically thought of as private is directly exposed to Apple, some difficult to avoid leaks still occur. For example, Apple could correlate that two or more users frequently receive a push notification at almost the exact same time. From this, Apple could potentially infer that these users are receiving the same message. For most users this may not be a significant new loss of privacy. -------------------------------------------------------------------------------- /aps/apns.go: -------------------------------------------------------------------------------- 1 | package aps 2 | 3 | import ( 4 | "crypto/x509" 5 | "encoding/pem" 6 | "errors" 7 | "github.com/go-redis/redis" 8 | log "github.com/sirupsen/logrus" 9 | "github.com/st3fan/dovecot-xaps-daemon/database" 10 | "github.com/timehop/apns" 11 | "io/ioutil" 12 | "sync" 13 | "time" 14 | ) 15 | 16 | const timeLayout = time.RFC3339 17 | 18 | var oidUid = []int{0, 9, 2342, 19200300, 100, 1, 1} 19 | var productionOID = []int{1, 2, 840, 113635, 100, 6, 3, 2} 20 | 21 | var client apns.Client 22 | var db *database.Database 23 | var redisClient *redis.Client 24 | var mapMutex = &sync.Mutex{} 25 | var delayedApns = make(map[database.Registration]time.Time) 26 | var delayTime = 30 27 | 28 | func NewApns( 29 | certFile string, 30 | keyFile string, 31 | checkDelayedInterval int, 32 | delayMessageTime int, 33 | feedbackInterval int, 34 | database *database.Database, 35 | redisEnabled bool, 36 | redisURL string, 37 | redisPassword string, 38 | redisDb int) string { 39 | log.Debugln("APNS for non NewMessage events will be delayed for", time.Second*time.Duration(delayTime)) 40 | delayTime = delayMessageTime 41 | db = database 42 | log.Debugln("Parsing", certFile, "to obtain APNS Topic") 43 | certtopic, err := topicFromCertificate(certFile) 44 | if err != nil { 45 | log.Fatalln("Could not parse apns topic from certificate: ", err) 46 | } 47 | log.Debugln("Topic is", certtopic) 48 | 49 | log.Debugln("Creating APNS client to", apns.ProductionGateway) 50 | client, err = apns.NewClientWithFiles(apns.ProductionGateway, certFile, keyFile) 51 | if err != nil { 52 | log.Fatal("Could not create client: ", err.Error()) 53 | } 54 | 55 | if redisEnabled { 56 | redisClient = redis.NewClient(&redis.Options{ 57 | Addr: redisURL, 58 | Password: redisPassword, // no password set 59 | DB: redisDb, // use default DB 60 | }) 61 | } 62 | 63 | // https://developer.apple.com/library/archive/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/BinaryProviderAPI.html 64 | feedback, err := apns.NewFeedbackWithFiles(apns.ProductionFeedbackGateway, certFile, keyFile) 65 | if err != nil { 66 | log.Fatal("Could not create feedback service: ", err.Error()) 67 | } 68 | if feedbackInterval > 0 { 69 | feedbackTicker := time.NewTicker(time.Minute * time.Duration(feedbackInterval)) 70 | go func() { 71 | for range feedbackTicker.C { 72 | for f := range feedback.Receive() { 73 | if !db.DeleteIfExistRegistration(f.DeviceToken, f.Timestamp) && 74 | redisEnabled { 75 | redisClient.HSet("xapsd", f.DeviceToken, f.Timestamp.Format(timeLayout)) 76 | } 77 | } 78 | if redisEnabled { 79 | list, err := redisClient.HGetAll("xapsd").Result() 80 | if err != nil { 81 | log.Errorln(err) 82 | } 83 | for key, value := range list { 84 | t, err := time.Parse(timeLayout, value) 85 | if err != nil { 86 | log.Errorln(err) 87 | } 88 | if db.DeleteIfExistRegistration(key, t) { 89 | redisClient.HDel("xapsd", key) 90 | } 91 | } 92 | } 93 | } 94 | }() 95 | } 96 | 97 | go func() { 98 | for f := range client.FailedNotifs { 99 | log.Println("Notification", f.Notif.ID, "failed with", f.Err.Error()) 100 | } 101 | }() 102 | 103 | delayedNotificationTicker := time.NewTicker(time.Second * time.Duration(checkDelayedInterval)) 104 | go func() { 105 | for range delayedNotificationTicker.C { 106 | checkDelayed() 107 | } 108 | }() 109 | 110 | return certtopic 111 | } 112 | 113 | func checkDelayed() { 114 | log.Debugln("Checking all delayed APNS") 115 | var sendNow []database.Registration 116 | mapMutex.Lock() 117 | for reg, t := range delayedApns { 118 | log.Debugln("Registration", reg.AccountId, "/", reg.DeviceToken, "has been waiting for", time.Since(t)) 119 | if time.Since(t) > time.Second*time.Duration(delayTime) { 120 | sendNow = append(sendNow, reg) 121 | delete(delayedApns, reg) 122 | } 123 | } 124 | mapMutex.Unlock() 125 | for _, reg := range sendNow { 126 | SendNotification(reg, false) 127 | } 128 | } 129 | 130 | func SendNotification(registration database.Registration, delayed bool) { 131 | mapMutex.Lock() 132 | if delayed { 133 | delayedApns[registration] = time.Now() 134 | mapMutex.Unlock() 135 | return 136 | } else { 137 | delete(delayedApns, registration) 138 | } 139 | mapMutex.Unlock() 140 | log.Debugln("Sending notification to", registration.AccountId, "/", registration.DeviceToken) 141 | payload := apns.NewPayload() 142 | payload.APS.AccountId = registration.AccountId 143 | notification := apns.NewNotification() 144 | notification.Payload = payload 145 | notification.DeviceToken = registration.DeviceToken 146 | // set expiration 147 | // https://developer.apple.com/library/content/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/CommunicatingwithAPNs.html 148 | t := time.Now().Add(24 * time.Hour) 149 | notification.Expiration = &t 150 | client.Send(notification) 151 | } 152 | 153 | func topicFromCertificate(filename string) (string, error) { 154 | data, err := ioutil.ReadFile(filename) 155 | if err != nil { 156 | log.Fatalln("Could not read file: ", err) 157 | } 158 | block, _ := pem.Decode([]byte(data)) 159 | if block == nil { 160 | return "", errors.New("Could not decode PEM block from certificate") 161 | } 162 | 163 | cert, err := x509.ParseCertificate(block.Bytes) 164 | if err != nil { 165 | log.Fatalln("Could not parse certificate: ", err) 166 | } 167 | 168 | if len(cert.Subject.Names) == 0 { 169 | return "", errors.New("Subject.Names is empty") 170 | } 171 | 172 | if !cert.Subject.Names[0].Type.Equal(oidUid) { 173 | return "", errors.New("did not find a Subject.Names[0] with type 0.9.2342.19200300.100.1.1") 174 | } 175 | 176 | if !cert.Extensions[7].Id.Equal(productionOID) { 177 | return "", errors.New("did not find an Extensions[7] with Id 1.2.840.113635.100.6.3.2 " + 178 | "which would label this certificate for production use") 179 | } 180 | 181 | return cert.Subject.Names[0].Value.(string), nil 182 | } 183 | -------------------------------------------------------------------------------- /database/database.go: -------------------------------------------------------------------------------- 1 | // 2 | // The MIT License (MIT) 3 | // 4 | // Copyright (c) 2015 Stefan Arentz 5 | // Copyright (c) 2017 Frederik Schwan 6 | // 7 | // Permission is hereby granted, free of charge, to any person obtaining a copy 8 | // of this software and associated documentation files (the "Software"), to deal 9 | // in the Software without restriction, including without limitation the rights 10 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | // copies of the Software, and to permit persons to whom the Software is 12 | // furnished to do so, subject to the following conditions: 13 | // 14 | // The above copyright notice and this permission notice shall be included in 15 | // all copies or substantial portions of the Software. 16 | // 17 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | // THE SOFTWARE. 24 | // 25 | 26 | package database 27 | 28 | import ( 29 | "encoding/json" 30 | "io/ioutil" 31 | "os" 32 | "sync" 33 | "time" 34 | ) 35 | 36 | var dbMutex = &sync.Mutex{} 37 | 38 | type Registration struct { 39 | DeviceToken string 40 | AccountId string 41 | } 42 | 43 | type Account struct { 44 | DeviceToken string 45 | Mailboxes []string 46 | RegistrationTime time.Time 47 | } 48 | 49 | func (account *Account) ContainsMailbox(mailbox string) bool { 50 | for _, m := range account.Mailboxes { 51 | if m == mailbox { 52 | return true 53 | } 54 | } 55 | return false 56 | } 57 | 58 | type User struct { 59 | Accounts map[string]Account 60 | } 61 | 62 | type Database struct { 63 | filename string 64 | Users map[string]User 65 | } 66 | 67 | func NewDatabase(filename string) (*Database, error) { 68 | // check if file exists 69 | _, err := os.Stat(filename) 70 | if err != nil && os.IsNotExist(err) { 71 | db := &Database{filename: filename, Users: make(map[string]User)} 72 | err := db.write() 73 | if err != nil { 74 | return nil, err 75 | } 76 | return db, nil 77 | } 78 | 79 | data, err := ioutil.ReadFile(filename) 80 | if err != nil { 81 | return nil, err 82 | } 83 | db := Database{filename: filename, Users: make(map[string]User)} 84 | if len(data) != 0 { 85 | err := json.Unmarshal(data, &db) 86 | if err != nil { 87 | return nil, err 88 | } 89 | } 90 | 91 | return &db, nil 92 | } 93 | 94 | func (db *Database) write() error { 95 | data, err := json.MarshalIndent(db, "", " ") 96 | if err != nil { 97 | return err 98 | } 99 | 100 | return ioutil.WriteFile(db.filename, data, 0644) 101 | } 102 | 103 | func (db *Database) AddRegistration(username, accountId, deviceToken string, mailboxes []string) error { 104 | // mutual write access to database issue #16 xaps-plugin 105 | dbMutex.Lock() 106 | 107 | // Ensure the User exists 108 | if _, ok := db.Users[username]; !ok { 109 | db.Users[username] = User{Accounts: make(map[string]Account)} 110 | } 111 | 112 | // Ensure the Account exists 113 | if _, ok := db.Users[username].Accounts[accountId]; !ok { 114 | db.Users[username].Accounts[accountId] = Account{} 115 | } 116 | 117 | // Set or update the Registration 118 | db.Users[username].Accounts[accountId] = 119 | Account{ 120 | DeviceToken: deviceToken, 121 | Mailboxes: mailboxes, 122 | RegistrationTime: time.Now(), 123 | } 124 | 125 | err := db.write() 126 | 127 | // release mutex 128 | dbMutex.Unlock() 129 | return err 130 | } 131 | 132 | func (db *Database) DeleteIfExistRegistration(deviceToken string, deletedTimestamp time.Time) bool { 133 | for _, user := range db.Users { 134 | for accountId, account := range user.Accounts { 135 | if account.DeviceToken == deviceToken { 136 | if !account.RegistrationTime.IsZero() && account.RegistrationTime.Before(deletedTimestamp) { 137 | dbMutex.Lock() 138 | delete(user.Accounts, accountId) 139 | dbMutex.Unlock() 140 | return true 141 | } else { 142 | return false 143 | } 144 | } 145 | } 146 | } 147 | return false 148 | } 149 | 150 | func (db *Database) FindRegistrations(username, mailbox string) ([]Registration, error) { 151 | var registrations []Registration 152 | if user, ok := db.Users[username]; ok { 153 | for accountId, account := range user.Accounts { 154 | if account.ContainsMailbox(mailbox) { 155 | registrations = append(registrations, 156 | Registration{DeviceToken: account.DeviceToken, AccountId: accountId}) 157 | } 158 | } 159 | } 160 | return registrations, nil 161 | } 162 | -------------------------------------------------------------------------------- /database/database_test.go: -------------------------------------------------------------------------------- 1 | // 2 | // The MIT License (MIT) 3 | // 4 | // Copyright (c) 2015 Stefan Arentz 5 | // Copyright (c) 2017 Frederik Schwan 6 | // 7 | // Permission is hereby granted, free of charge, to any person obtaining a copy 8 | // of this software and associated documentation files (the "Software"), to deal 9 | // in the Software without restriction, including without limitation the rights 10 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | // copies of the Software, and to permit persons to whom the Software is 12 | // furnished to do so, subject to the following conditions: 13 | // 14 | // The above copyright notice and this permission notice shall be included in 15 | // all copies or substantial portions of the Software. 16 | // 17 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | // THE SOFTWARE. 24 | // 25 | 26 | package database 27 | 28 | import ( 29 | "io/ioutil" 30 | "os" 31 | "testing" 32 | "time" 33 | ) 34 | 35 | func TestDatabase_NewDatabase(t *testing.T) { 36 | db, err := NewDatabase("testdata/database.json") 37 | if err != nil { 38 | t.Error("Cannot open database testdata/database.json", err) 39 | } 40 | 41 | if db.filename != "testdata/database.json" { 42 | t.Error(`db.filename != "testdata/database.json"`) 43 | } 44 | 45 | if len(db.Users) != 2 { 46 | t.Error("len(db.Users) != 2") 47 | } 48 | 49 | if len(db.Users["stefan"].Accounts) != 2 { 50 | t.Error(`db.Users["stefan"].Accounts) != 1`) 51 | } 52 | 53 | if len(db.Users["alice"].Accounts) != 1 { 54 | t.Error(`db.Users["alice"].Accounts) != 1`) 55 | } 56 | } 57 | 58 | func TestDatabase_AddRegistration(t *testing.T) { 59 | f, err := ioutil.TempFile("/tmp", "database_test_Test_addRegistration") 60 | if err != nil { 61 | t.Error("Can't create temporary file", err) 62 | } 63 | defer os.Remove(f.Name()) 64 | 65 | db, err := NewDatabase(f.Name()) 66 | if err != nil { 67 | t.Error("Cannot open database", err) 68 | } 69 | 70 | if err := db.AddRegistration("test@example.com", "testaccountid1", "testtoken1", []string{"Inbox", "Spam"}); err != nil { 71 | t.Error("Cannot addRegistration:", err) 72 | } 73 | 74 | if err := db.AddRegistration("test@example.com", "testaccountid2", "testtoken2", []string{"Inbox", "Ham"}); err != nil { 75 | t.Error("Cannot addRegistration:", err) 76 | } 77 | 78 | if err := db.AddRegistration("alice@example.com", "aliceaccountid", "alicetoken", []string{"Inbox", "Important"}); err != nil { 79 | t.Error("Cannot addRegistration:", err) 80 | } 81 | 82 | db, err = NewDatabase(f.Name()) 83 | if err != nil { 84 | t.Error("Cannot open database", err) 85 | } 86 | 87 | if _, ok := db.Users["test@example.com"]; !ok { 88 | t.Error(`Cannot find Users["test@example.com"]`) 89 | } 90 | 91 | if _, ok := db.Users["alice@example.com"]; !ok { 92 | t.Error(`Cannot find Users["test@example.com"]`) 93 | } 94 | } 95 | 96 | func TestDatabase_FindRegistrations(t *testing.T) { 97 | db, err := NewDatabase("testdata/database.json") 98 | if err != nil { 99 | t.Error("Cannot open database testdata/database.json", err) 100 | } 101 | 102 | registrations, err := db.FindRegistrations("stefan", "Inbox") 103 | if err != nil { 104 | t.Error("Cannot findRegistrations:", err) 105 | } 106 | 107 | if len(registrations) != 2 { 108 | t.Error(`len(registrations) != 2`) 109 | } 110 | 111 | registrations, err = db.FindRegistrations("stefan", "Ham") 112 | if err != nil { 113 | t.Error("Cannot findRegistrations:", err) 114 | } 115 | 116 | if len(registrations) != 1 { 117 | t.Error(`len(registrations) != 1`) 118 | } 119 | 120 | registrations, err = db.FindRegistrations("doesnotexist", "Inbox") 121 | if err != nil { 122 | t.Error("Cannot findRegistrations:", err) 123 | } 124 | 125 | if len(registrations) != 0 { 126 | t.Error(`len(registrations) != 0`) 127 | } 128 | } 129 | 130 | func TestDatabase_AccountContainsMailbox(t *testing.T) { 131 | account := Account{DeviceToken: "SomeToken", Mailboxes: []string{"Inbox", "Ham"}} 132 | 133 | if account.ContainsMailbox("Inbox") != true { 134 | t.Error(`account.ContainsMailbox("Inbox") != true`) 135 | } 136 | 137 | if account.ContainsMailbox("Ham") != true { 138 | t.Error(`account.ContainsMailbox("Ham") != true`) 139 | } 140 | 141 | if account.ContainsMailbox("Cheese") != false { 142 | t.Error(`account.ContainsMailbox("Cheese") != false`) 143 | } 144 | } 145 | 146 | func TestDatabase_DeleteIfExistRegistration(t *testing.T) { 147 | db, err := NewDatabase("testdata/database.json") 148 | if err != nil { 149 | t.Error("Cannot open database testdata/database.json", err) 150 | } 151 | 152 | success := db.DeleteIfExistRegistration("alicedevicetoken1", time.Now()) 153 | if !success { 154 | t.Error("Device token could not be removed", err) 155 | } 156 | 157 | success = db.DeleteIfExistRegistration("alicedevicetoken1", time.Now()) 158 | if success { 159 | t.Error("Not existend device token has been *successfully* deleted???", err) 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /database/testdata/database.json: -------------------------------------------------------------------------------- 1 | { 2 | "Users": { 3 | "alice": { 4 | "Accounts": { 5 | "aliceaccountid1": { 6 | "DeviceToken": "alicedevicetoken1", 7 | "RegistrationTime": "2018-09-22T12:42:31Z", 8 | "Mailboxes": [ 9 | "Inbox" 10 | ] 11 | } 12 | } 13 | }, 14 | "stefan": { 15 | "Accounts": { 16 | "stefanaccountid1": { 17 | "DeviceToken": "stefandevicetoken1", 18 | "Mailboxes": [ 19 | "Inbox" 20 | ] 21 | }, 22 | "stefanaccountid2": { 23 | "DeviceToken": "stefandevicetoken2", 24 | "Mailboxes": [ 25 | "Inbox", 26 | "Ham" 27 | ] 28 | } 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /etc/systemd/xapsd.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Apple Push Notification Service 3 | After=network.target auditd.service 4 | 5 | [Service] 6 | User=root 7 | EnvironmentFile=/etc/xapsd/xapsd.conf 8 | ExecStart=/usr/bin/xapsd -key=/etc/xapsd/${KEY_FILE} \ 9 | -certificate=/etc/xapsd/${CERT_FILE} \ 10 | -database=/var/lib/xapsd/xapsd.json \ 11 | -socket=/var/run/dovecot/xapsd.sock \ 12 | -loglevel=${LOGLEVEL} \ 13 | -delayCheckInterval=${CHECKINTERVAL} \ 14 | -delayTime=${DELAY} \ 15 | -feedbackInterval=${FEEDBACK_INTERVAL} \ 16 | -redisEnabled=${REDIS_ENABLED} \ 17 | -redisUrl=${REDIS_URL} \ 18 | -redisPassword=${REDIS_PASSWORD} \ 19 | -redisDb=${REDIS_DB} 20 | ExecReload=/bin/kill -HUP $MAINPID 21 | KillMode=process 22 | Restart=on-failure 23 | 24 | [Install] 25 | WantedBy=multi-user.target 26 | -------------------------------------------------------------------------------- /etc/xapsd/xapsd.conf: -------------------------------------------------------------------------------- 1 | KEY_FILE=key.pem 2 | CERT_FILE=certificate.pem 3 | LOGLEVEL=info 4 | CHECKINTERVAL=20 5 | DELAY=30 6 | FEEDBACK_INTERVAL=60 7 | REDIS_ENABLED=false 8 | REDIS_URL=localhost:6379 9 | REDIS_PASSWORD= 10 | REDIS_DB=0 -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module dovecot-xaps-daemon 2 | 3 | go 1.12 4 | 5 | require ( 6 | github.com/go-redis/redis v6.15.2+incompatible 7 | github.com/onsi/ginkgo v0.0.0-20180119174237-747514b53ddd // indirect 8 | github.com/onsi/gomega v1.3.0 // indirect 9 | github.com/sirupsen/logrus v0.0.0-20180129181852-768a92a02685 10 | github.com/st3fan/dovecot-xaps-daemon v0.0.0-20190315235014-af50c8175e09 11 | github.com/timehop/apns v0.0.0-20160922055839-7dfe710e494f 12 | golang.org/x/crypto v0.0.0-20180127211104-1875d0a70c90 // indirect 13 | golang.org/x/net v0.0.0-20180124060956-0ed95abb35c4 // indirect 14 | golang.org/x/sys v0.0.0-20180126165840-ff2a66f350ce // indirect 15 | golang.org/x/text v0.0.0-20171227012246-e19ae1496984 // indirect 16 | gopkg.in/yaml.v2 v2.0.0 // indirect 17 | ) 18 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/go-redis/redis v6.15.2+incompatible h1:9SpNVG76gr6InJGxoZ6IuuxaCOQwDAhzyXg+Bs+0Sb4= 2 | github.com/go-redis/redis v6.15.2+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA= 3 | github.com/onsi/ginkgo v0.0.0-20180119174237-747514b53ddd/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 4 | github.com/onsi/gomega v1.3.0/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= 5 | github.com/sirupsen/logrus v0.0.0-20180129181852-768a92a02685 h1:lJ4DJ+cfcgJnYAVMNSkDfIOIHtpV/SNO2xJultzMDic= 6 | github.com/sirupsen/logrus v0.0.0-20180129181852-768a92a02685/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc= 7 | github.com/st3fan/dovecot-xaps-daemon v0.0.0-20190315235014-af50c8175e09 h1:utWKXyW1EJIE2aNJpNho0xLf9EgpmkOdywr0xFCbwAo= 8 | github.com/st3fan/dovecot-xaps-daemon v0.0.0-20190315235014-af50c8175e09/go.mod h1:mKWeebiD0ONOd/hKHklu5Y9359VccQmo0gyClyEwoTs= 9 | github.com/timehop/apns v0.0.0-20160922055839-7dfe710e494f h1:H1Opw5BBEDicJtpsgCRjXaPE88aSKI8frJam2caifW8= 10 | github.com/timehop/apns v0.0.0-20160922055839-7dfe710e494f/go.mod h1:khuqRtFUAy1omVO/Qfu1Wq3V3uumCJN2MbG0H+rswpQ= 11 | golang.org/x/crypto v0.0.0-20180127211104-1875d0a70c90 h1:DNyuYmiOz3AH2rGH1n4YsZUvxVhkeMvSs8s31jiWpm0= 12 | golang.org/x/crypto v0.0.0-20180127211104-1875d0a70c90/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 13 | golang.org/x/net v0.0.0-20180124060956-0ed95abb35c4/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 14 | golang.org/x/sys v0.0.0-20180126165840-ff2a66f350ce h1:rL/NvE76zNX12KMlXYCdjlfOp+kjh5sYTnm5QjtNbrU= 15 | golang.org/x/sys v0.0.0-20180126165840-ff2a66f350ce/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 16 | golang.org/x/text v0.0.0-20171227012246-e19ae1496984/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 17 | gopkg.in/yaml.v2 v2.0.0/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= 18 | -------------------------------------------------------------------------------- /logger/logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | log "github.com/sirupsen/logrus" 5 | "os" 6 | ) 7 | 8 | func init() { 9 | // optional use the json formatter 10 | log.SetFormatter(&log.TextFormatter{}) 11 | 12 | log.SetOutput(os.Stdout) 13 | 14 | // by default use the warning loglevel 15 | log.SetLevel(log.WarnLevel) 16 | } 17 | 18 | func ParseLoglevel(argument string) { 19 | switch argument { 20 | case "debug": 21 | log.Warn("Logging set to debug") 22 | log.SetLevel(log.DebugLevel) 23 | case "error": 24 | log.Warn("Logging set to error") 25 | log.SetLevel(log.ErrorLevel) 26 | case "fatal": 27 | log.Warn("Logging set to fatal") 28 | log.SetLevel(log.FatalLevel) 29 | case "info": 30 | log.Warn("Logging set to info") 31 | log.SetLevel(log.InfoLevel) 32 | case "panic": 33 | log.Warn("Logging set to panic") 34 | log.SetLevel(log.PanicLevel) 35 | case "warn": 36 | log.Warn("Logging set to warn") 37 | log.SetLevel(log.WarnLevel) 38 | default: 39 | log.Warn("The provided LogLevel is not of type debug, error, fatal, info, warn or panic - setting to warn instead") 40 | // we do not need to set the loglevel here since warn is set in init() 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /socket/socket.go: -------------------------------------------------------------------------------- 1 | package socket 2 | 3 | import ( 4 | "bufio" 5 | "errors" 6 | log "github.com/sirupsen/logrus" 7 | "github.com/st3fan/dovecot-xaps-daemon/aps" 8 | "github.com/st3fan/dovecot-xaps-daemon/database" 9 | "net" 10 | "os" 11 | "strings" 12 | ) 13 | 14 | type command struct { 15 | name string 16 | args map[string]interface{} 17 | } 18 | 19 | func NewSocket(socketpath string, db *database.Database, topic string) { 20 | // Delete the socketpath if it already exists 21 | if _, err := os.Stat(socketpath); err == nil { 22 | err := os.Remove(socketpath) 23 | if err != nil { 24 | log.Fatalln("Could not delete existing socketpath: ", socketpath, err) 25 | } 26 | } 27 | log.Debugln("Listening on UNIX socketpath at", socketpath) 28 | 29 | listener, err := net.Listen("unix", socketpath) 30 | if err != nil { 31 | log.Fatalln("Could not create socketpath: ", err) 32 | } 33 | defer os.Remove(socketpath) 34 | 35 | // TODO What is the proper way to limit Dovecot to this socketpath 36 | err = os.Chmod(socketpath, 0777) 37 | if err != nil { 38 | log.Fatalln("Could not chmod socketpath: ", err) 39 | } 40 | 41 | for { 42 | conn, err := listener.Accept() 43 | if err != nil { 44 | log.Println("Failed to accept connection: ", err.Error()) 45 | os.Exit(1) 46 | } 47 | 48 | log.Debugln("Accepted a connection") 49 | go handleRequest(conn, db, topic) 50 | } 51 | } 52 | 53 | func parseListValue(value string) ([]string, error) { 54 | var list []string 55 | values := strings.Split(value[1:len(value)-1], ",") 56 | for _, value := range values { 57 | stringValue, err := parseStringValue(value) 58 | if err != nil { 59 | return nil, err 60 | } 61 | list = append(list, stringValue) 62 | } 63 | return list, nil 64 | } 65 | 66 | func parseStringValue(value string) (string, error) { 67 | return value[1 : len(value)-1], nil // TODO Escaping! 68 | } 69 | 70 | func parseCommand(line string) (command, error) { 71 | cmd := command{args: make(map[string]interface{})} 72 | 73 | parts := strings.SplitN(line, " ", 2) 74 | if len(parts) != 2 { 75 | return cmd, errors.New("Failed to parse: no name found") 76 | } 77 | 78 | cmd.name = parts[0] 79 | 80 | for _, pair := range strings.Split(parts[1], "\t") { 81 | nameAndValue := strings.SplitN(pair, "=", 2) 82 | if len(nameAndValue) != 2 { 83 | return cmd, errors.New("Failed to parse: no name/value pair found") 84 | } 85 | 86 | switch { 87 | case strings.HasPrefix(nameAndValue[1], `"`) && strings.HasSuffix(nameAndValue[1], `"`): 88 | value, err := parseStringValue(nameAndValue[1]) 89 | if err != nil { 90 | return cmd, err 91 | } 92 | cmd.args[nameAndValue[0]] = value 93 | case strings.HasPrefix(nameAndValue[1], "(") && strings.HasSuffix(nameAndValue[1], ")"): 94 | value, err := parseListValue(nameAndValue[1]) 95 | if err != nil { 96 | return cmd, err 97 | } 98 | cmd.args[nameAndValue[0]] = value 99 | default: 100 | return cmd, errors.New("Failed to parse: invalid value in key/value pair") 101 | } 102 | } 103 | 104 | return cmd, nil 105 | } 106 | 107 | func handleRequest(conn net.Conn, db *database.Database, topic string) { 108 | defer conn.Close() 109 | 110 | scanner := bufio.NewScanner(conn) 111 | for scanner.Scan() { 112 | log.Debugln("Received request:", scanner.Text()) 113 | 114 | command, err := parseCommand(scanner.Text()) 115 | if err != nil { 116 | log.Fatalln("Error parsing socket data: ", err) 117 | } 118 | 119 | switch command.name { 120 | case "REGISTER": 121 | handleRegister(conn, command, db, topic) 122 | case "NOTIFY": 123 | handleNotify(conn, command, db) 124 | default: 125 | writeError(conn, "Unknown command") 126 | } 127 | } 128 | 129 | err := scanner.Err() 130 | if err != nil { 131 | log.Fatalln("Error while reading from socket: ", err) 132 | } 133 | } 134 | 135 | // 136 | // Handle the REGISTER command. It looks as follows: 137 | // 138 | // REGISTER aps-account-id="AAA" aps-device-token="BBB" 139 | // aps-subtopic="com.apple.mobilemail" 140 | // dovecot-username="stefan" 141 | // dovecot-mailboxes=("Inbox","Notes") 142 | // 143 | // The command returns the aps-topic, which is the common name of 144 | // the certificate issued by OS X Server for email push 145 | // notifications. 146 | // 147 | func handleRegister(conn net.Conn, cmd command, db *database.Database, topic string) { 148 | // Make sure the subtopic is ok 149 | subtopic, ok := cmd.getStringArg("aps-subtopic") 150 | if !ok { 151 | writeError(conn, "Missing aps-subtopic argument") 152 | } 153 | if subtopic != "com.apple.mobilemail" { 154 | writeError(conn, "Unknown aps-subtopic") 155 | } 156 | 157 | // Make sure we got the required parameters 158 | accountId, ok := cmd.getStringArg("aps-account-id") 159 | if !ok { 160 | writeError(conn, "Missing aps-account-id argument") 161 | } 162 | deviceToken, ok := cmd.getStringArg("aps-device-token") 163 | if !ok { 164 | writeError(conn, "Missing aps-device-token argument") 165 | } 166 | username, ok := cmd.getStringArg("dovecot-username") 167 | if !ok { 168 | writeError(conn, "Missing dovecot-username argument") 169 | } 170 | mailboxes, ok := cmd.getListArg("dovecot-mailboxes") 171 | if !ok { 172 | writeError(conn, "Missing dovecot-mailboxes argument") 173 | } 174 | // Register this email/account-id/device-token combination 175 | err := db.AddRegistration(username, accountId, deviceToken, mailboxes) 176 | if !ok { 177 | writeError(conn, "Failed to register client: "+err.Error()) 178 | } 179 | writeSuccess(conn, topic) 180 | } 181 | 182 | // 183 | // Handle the NOTIFY command. It looks as follows: 184 | // 185 | // NOTIFY dovecot-username="stefan" dovecot-mailbox="Inbox" 186 | // 187 | // See if the the username has devices registered. If it has, loop 188 | // over them to find the ones that are interested in the named 189 | // mailbox and send those a push notificiation. 190 | // 191 | // The push notification looks like this: 192 | // 193 | // { "aps": { "account-id": aps-account-id } } 194 | // 195 | func handleNotify(conn net.Conn, cmd command, db *database.Database) { 196 | // Make sure we got the required arguments 197 | username, ok := cmd.getStringArg("dovecot-username") 198 | if !ok { 199 | writeError(conn, "Missing dovecot-username argument") 200 | } 201 | 202 | mailbox, ok := cmd.getStringArg("dovecot-mailbox") 203 | if !ok { 204 | writeError(conn, "Missing dovecot-mailbox argument") 205 | } 206 | 207 | isMessageNew := false 208 | events, ok := cmd.getListArg("events") 209 | if !ok { 210 | log.Warnln("No events found in NOTIFY message, please update the xaps-dovecot-plugin!") 211 | isMessageNew = true 212 | } 213 | 214 | // check if this is an event for a new message 215 | // for all possible events have a look at dovecot-core: 216 | // grep '#define EVENT_NAME' src/plugins/push-notification/push-notification-event* 217 | for _, e := range events { 218 | if e == "MessageNew" { 219 | isMessageNew = true 220 | } 221 | } 222 | 223 | // we don't know how to handle other mboxes other than INBOX, so ignore them 224 | if mailbox != "INBOX" { 225 | log.Debugln("Ignoring non INBOX event for:", mailbox) 226 | writeSuccess(conn, "") 227 | return 228 | } 229 | 230 | // Find all the devices registered for this mailbox event 231 | registrations, err := db.FindRegistrations(username, mailbox) 232 | if err != nil { 233 | writeError(conn, "Cannot lookup registrations: "+err.Error()) 234 | } 235 | 236 | // Send a notification to all registered devices. We ignore failures 237 | // because there is not a lot we can do. 238 | for _, registration := range registrations { 239 | aps.SendNotification(registration, !isMessageNew) 240 | } 241 | writeSuccess(conn, "") 242 | } 243 | 244 | func (cmd *command) getStringArg(name string) (string, bool) { 245 | arg, ok := cmd.args[name].(string) 246 | return arg, ok 247 | } 248 | 249 | func (cmd *command) getListArg(name string) ([]string, bool) { 250 | arg, ok := cmd.args[name].([]string) 251 | return arg, ok 252 | } 253 | 254 | func writeError(conn net.Conn, msg string) { 255 | log.Debugln("Returning failure:", msg) 256 | conn.Write([]byte("ERROR" + " " + msg + "\n")) 257 | } 258 | 259 | func writeSuccess(conn net.Conn, msg string) { 260 | log.Debugln("Returning success:", msg) 261 | conn.Write([]byte("OK" + " " + msg + "\n")) 262 | } 263 | -------------------------------------------------------------------------------- /socket/socket_test.go: -------------------------------------------------------------------------------- 1 | // 2 | // The MIT License (MIT) 3 | // 4 | // Copyright (c) 2015 Stefan Arentz 5 | // Copyright (c) 2017 Frederik Schwan 6 | // 7 | // Permission is hereby granted, free of charge, to any person obtaining a copy 8 | // of this software and associated documentation files (the "Software"), to deal 9 | // in the Software without restriction, including without limitation the rights 10 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | // copies of the Software, and to permit persons to whom the Software is 12 | // furnished to do so, subject to the following conditions: 13 | // 14 | // The above copyright notice and this permission notice shall be included in 15 | // all copies or substantial portions of the Software. 16 | // 17 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | // THE SOFTWARE. 24 | // 25 | 26 | package socket 27 | 28 | import ( 29 | "testing" 30 | ) 31 | 32 | func Test_ParseCommand_Register(t *testing.T) { 33 | line := "REGISTER aps-account-id=\"AAA\"\taps-device-token=\"BBB\"\taps-subtopic=\"com.apple.mobilemail\"\tdovecot-username=\"stefan\"\tdovecot-mailboxes=(\"Inbox\",\"Notes\")" 34 | 35 | cmd, err := parseCommand(line) 36 | if err != nil { 37 | t.Error("Cannot parseCommand", err) 38 | } 39 | 40 | if cmd.name != "REGISTER" { 41 | t.Error(`cmd.name != "REGISTER"`) 42 | } 43 | 44 | if val, _ := cmd.getStringArg("aps-account-id"); val != "AAA" { 45 | t.Error(`val != "AAA" ` + val) 46 | } 47 | 48 | if val, _ := cmd.getStringArg("aps-device-token"); val != "BBB" { 49 | t.Error(`val != "BBB"` + val) 50 | } 51 | 52 | if val, _ := cmd.getStringArg("aps-subtopic"); val != "com.apple.mobilemail" { 53 | t.Error(`val != "com.apple.mobilemail"` + val) 54 | } 55 | 56 | if val, _ := cmd.getStringArg("dovecot-username"); val != "stefan" { 57 | t.Error(`val != "stefan"` + val) 58 | } 59 | 60 | if val, ok := cmd.getListArg("dovecot-mailboxes"); !ok || len(val) != 2 || val[0] != "Inbox" || val[1] != "Notes" { 61 | t.Error(`Cannot getListArg("dovecot-mailboxes")`) 62 | } 63 | } 64 | 65 | func Test_ParseCommand_Notify(t *testing.T) { 66 | line := "NOTIFY dovecot-username=\"stefan\"\tdovecot-mailbox=\"Inbox\"" 67 | 68 | cmd, err := parseCommand(line) 69 | if err != nil { 70 | t.Error("Cannot parseCommand", err) 71 | } 72 | 73 | if cmd.name != "NOTIFY" { 74 | t.Error(`cmd.name != "NOTIFY"`) 75 | } 76 | 77 | if val, _ := cmd.getStringArg("dovecot-username"); val != "stefan" { 78 | t.Error(`val != "stefan" ` + val) 79 | } 80 | 81 | if val, _ := cmd.getStringArg("dovecot-mailbox"); val != "Inbox" { 82 | t.Error(`val != "Inbox" ` + val) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /xapsd.go: -------------------------------------------------------------------------------- 1 | // 2 | // The MIT License (MIT) 3 | // 4 | // Copyright (c) 2015 Stefan Arentz 5 | // Copyright (c) 2017 Frederik Schwan 6 | // 7 | // Permission is hereby granted, free of charge, to any person obtaining a copy 8 | // of this software and associated documentation files (the "Software"), to deal 9 | // in the Software without restriction, including without limitation the rights 10 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | // copies of the Software, and to permit persons to whom the Software is 12 | // furnished to do so, subject to the following conditions: 13 | // 14 | // The above copyright notice and this permission notice shall be included in 15 | // all copies or substantial portions of the Software. 16 | // 17 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | // THE SOFTWARE. 24 | // 25 | 26 | package main 27 | 28 | import ( 29 | "flag" 30 | log "github.com/sirupsen/logrus" 31 | "github.com/st3fan/dovecot-xaps-daemon/aps" 32 | "github.com/st3fan/dovecot-xaps-daemon/database" 33 | "github.com/st3fan/dovecot-xaps-daemon/logger" 34 | "github.com/st3fan/dovecot-xaps-daemon/socket" 35 | ) 36 | 37 | const Version = "1.1" 38 | 39 | var logLevel = flag.String("loglevel", "warn", "Loglevel: debug, error, fatal, info, panic") 40 | var socketpath = flag.String("socket", "/var/run/xapsd/xapsd.sock", "path to the socketpath for Dovecot") 41 | var checkDelayedInterval = flag.Int("delayCheckInterval", 20, "interval to check for delayed push notifications to send") 42 | var delayMessageTime = flag.Int("delayTime", 30, "seconds to wait until a notification for a non NewMessage event gets sent") 43 | var apnsFeedbackTime = flag.Int("feedbackInterval", 0, "interval in minutes after which APNS feedback service is queried") 44 | var databasefile = flag.String("database", "/var/lib/xapsd/databasefile.json", "path to the databasefile file") 45 | var key = flag.String("key", "/etc/xapsd/key.pem", "path to the pem file containing the private key") 46 | var certificate = flag.String("certificate", "/etc/xapsd/certificate.pem", "path to the pem file containing the certificate") 47 | var redisEnabled = flag.Bool("redisEnabled", false, "Enable Redis to synchronize APNS Feedback Service") 48 | var redisUrl = flag.String("redisUrl", "localhost:6379", "redis URL") 49 | var redisPassword = flag.String("redisPassword", "", "redis Password") 50 | var redisDb = flag.Int("redisDb", 0, "redis Database") 51 | 52 | 53 | func main() { 54 | flag.Parse() 55 | logger.ParseLoglevel(*logLevel) 56 | 57 | log.Debugln("Opening databasefile at", *databasefile) 58 | db, err := database.NewDatabase(*databasefile) 59 | if err != nil { 60 | log.Fatal("Cannot open databasefile: ", *databasefile) 61 | } 62 | topic := aps.NewApns(*certificate, *key, *checkDelayedInterval, *delayMessageTime, *apnsFeedbackTime, db, *redisEnabled, *redisUrl, *redisPassword, *redisDb) 63 | 64 | log.Printf("Starting xapsd %s on %s", Version, *socketpath) 65 | socket.NewSocket(*socketpath, db, topic) 66 | } 67 | --------------------------------------------------------------------------------