├── .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 |
4 |
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 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
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 | [](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 |
--------------------------------------------------------------------------------