├── configs ├── systemd │ ├── xapsd.tmpfiles │ ├── xapsd-sysusers.conf │ └── xapsd.service └── xapsd │ └── xapsd.yaml ├── internal ├── config │ ├── testconf.yaml │ ├── config_test.go │ └── config.go ├── database │ ├── testdata │ │ └── database.json │ ├── database.go │ └── database_test.go ├── socket.go └── apns.go ├── .github └── workflows │ └── test.yml ├── go.mod ├── LICENSE ├── .gitignore ├── cmd └── xapsd │ └── xapsd.go ├── go.sum └── README.md /configs/systemd/xapsd.tmpfiles: -------------------------------------------------------------------------------- 1 | d /var/lib/xapsd 755 xapsd xapsd 2 | -------------------------------------------------------------------------------- /configs/systemd/xapsd-sysusers.conf: -------------------------------------------------------------------------------- 1 | u xapsd - "xapsd" /var/lib/xapsd 2 | -------------------------------------------------------------------------------- /internal/config/testconf.yaml: -------------------------------------------------------------------------------- 1 | loglevel: info 2 | databaseFile: /var/lib/xapsd/database.json 3 | KeyFileP8: key.p8 4 | KeyFileTopic: com.apple.mail.nil 5 | keyFileKeyId: ABCDEFGH 6 | keyFileTeamId: ABCDEFGH 7 | port: 11619 8 | checkInterval: 20 9 | delay: 30 10 | -------------------------------------------------------------------------------- /internal/config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestConfig_GetOptions(t *testing.T) { 8 | ParseConfig("testconf", "./") 9 | options := GetOptions() 10 | if options.LogLevel != "info" { 11 | t.Error("Config not loaded") 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | name: Test 3 | jobs: 4 | test: 5 | strategy: 6 | matrix: 7 | go-version: [1.22.x, 1.23.x] 8 | platform: [ubuntu-latest, macos-latest] 9 | runs-on: ${{ matrix.platform }} 10 | steps: 11 | - name: Install Go 12 | uses: actions/setup-go@v2 13 | with: 14 | go-version: ${{ matrix.go-version }} 15 | - name: Checkout code 16 | uses: actions/checkout@v2 17 | - name: Test 18 | run: go test ./... 19 | - name: Build 20 | run: go build ./cmd/xapsd 21 | -------------------------------------------------------------------------------- /internal/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 | -------------------------------------------------------------------------------- /configs/systemd/xapsd.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Apple Push Notification Service 3 | After=network.target auditd.service 4 | 5 | [Service] 6 | User=xapsd 7 | Group=xapsd 8 | ExecStart=/usr/bin/xapsd 9 | Restart=on-failure 10 | 11 | # Each IMAP process creates a persistent HTTP connection 12 | LimitNOFILE=1024000 13 | 14 | # Hardening 15 | MemoryDenyWriteExecute=true 16 | NoNewPrivileges=true 17 | PrivateDevices=true 18 | PrivateTmp=true 19 | ProtectHome=true 20 | ProtectControlGroups=true 21 | ProtectKernelModules=true 22 | ProtectSystem=strict 23 | RestrictRealtime=true 24 | SystemCallArchitectures=native 25 | SystemCallFilter=@system-service 26 | RestrictNamespaces=yes 27 | LockPersonality=yes 28 | RestrictSUIDSGID=yes 29 | ReadWritePaths=/var/lib/xapsd 30 | 31 | [Install] 32 | WantedBy=multi-user.target 33 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/freswa/dovecot-xaps-daemon 2 | 3 | go 1.24.0 4 | 5 | toolchain go1.24.2 6 | 7 | require ( 8 | github.com/julienschmidt/httprouter v1.3.0 9 | github.com/sideshow/apns2 v0.25.0 10 | github.com/sirupsen/logrus v1.9.3 11 | github.com/spf13/viper v1.20.1 12 | ) 13 | 14 | require ( 15 | github.com/fsnotify/fsnotify v1.9.0 // indirect 16 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect 17 | github.com/golang-jwt/jwt/v4 v4.5.2 // indirect 18 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect 19 | github.com/sagikazarmark/locafero v0.9.0 // indirect 20 | github.com/sourcegraph/conc v0.3.0 // indirect 21 | github.com/spf13/afero v1.14.0 // indirect 22 | github.com/spf13/cast v1.9.2 // indirect 23 | github.com/spf13/pflag v1.0.7 // indirect 24 | github.com/subosito/gotenv v1.6.0 // indirect 25 | go.uber.org/multierr v1.11.0 // indirect 26 | golang.org/x/crypto v0.45.0 // indirect 27 | golang.org/x/net v0.47.0 // indirect 28 | golang.org/x/sys v0.38.0 // indirect 29 | golang.org/x/text v0.31.0 // indirect 30 | gopkg.in/yaml.v3 v3.0.1 // indirect 31 | ) 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Stefan Arentz 4 | Copyright (c) 2018 Frederik Schwan linux.com 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | log "github.com/sirupsen/logrus" 5 | "github.com/spf13/viper" 6 | ) 7 | 8 | var conf Config 9 | 10 | type ( 11 | Config struct { 12 | loaded bool 13 | LogLevel string 14 | DatabaseFile string 15 | Port string 16 | ListenAddr string 17 | CheckInterval uint 18 | Delay uint 19 | CertificateFileP12 string 20 | CertificateFilePem string 21 | CertificateFilePemKey string 22 | KeyFileP8 string 23 | KeyFileTopic string 24 | KeyFileKeyId string 25 | KeyFileTeamId string 26 | TlsCertfile string 27 | TlsKeyfile string 28 | TlsPort string 29 | TlsListenAddr string 30 | } 31 | ) 32 | 33 | func ParseConfig(configName, configPath string) { 34 | viper.SetConfigType("yaml") 35 | viper.SetConfigName("xapsd") 36 | viper.SetConfigName(configName) 37 | viper.AddConfigPath("/etc/xapsd/") 38 | viper.AddConfigPath(configPath) 39 | 40 | err := viper.ReadInConfig() 41 | if err != nil { 42 | log.Fatal(err) 43 | } 44 | err = viper.Unmarshal(&conf) 45 | if err != nil { 46 | log.Fatal(err) 47 | } 48 | conf.loaded = true 49 | } 50 | 51 | func GetOptions() Config { 52 | if !conf.loaded { 53 | ParseConfig("", "") 54 | } 55 | return conf 56 | } 57 | -------------------------------------------------------------------------------- /.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 | .idea/ 45 | 46 | # CMake 47 | cmake-build-debug/ 48 | 49 | ## File-based project format: 50 | *.iws 51 | 52 | ## Plugin-specific files: 53 | 54 | # IntelliJ 55 | out/ 56 | 57 | # mpeltonen/sbt-idea plugin 58 | .idea_modules/ 59 | 60 | # JIRA plugin 61 | atlassian-ide-plugin.xml 62 | 63 | # Crashlytics plugin (for Android Studio and IntelliJ) 64 | com_crashlytics_export_strings.xml 65 | crashlytics.properties 66 | crashlytics-build.properties 67 | fabric.properties 68 | 69 | -------------------------------------------------------------------------------- /configs/xapsd/xapsd.yaml: -------------------------------------------------------------------------------- 1 | # set the loglevel to either 2 | # trace, debug, error, fatal, info, panic or warn 3 | # Default: info 4 | loglevel: info 5 | 6 | # xapsd creates a json file to store the registration persistent on disk. 7 | # This sets the location of the file. 8 | databaseFile: /var/lib/xapsd/database.json 9 | 10 | # xapsd listens on a socket for http/https requests from the dovecot plugin. 11 | # This sets the address and port number of the listen socket. 12 | listenAddr: '[::1]' 13 | port: 11619 14 | 15 | # xapsd is able to listen on a HTTPS Socket to allow HTTP/2 to be used 16 | # SSL is enabled implicitly when certfile and keyfile exist 17 | # !!! only use HTTPS for connection pooling with a proxy e.g. nginx or HaProxy 18 | # !!! direct usage with the plugin is discouraged and unsupported 19 | tlsCertfile: 20 | tlsKeyfile: 21 | tlsListenAddr: 22 | tlsPort: 11620 23 | 24 | # Notifications that are not initiated by new messages are not sent immediately for two reasons: 25 | # 1. When you move/copy/delete messages you most likely move/copy/delete more messages within a short period of time. 26 | # 2. You don't need your mailboxes to synchronize immediately since they are automatically synchronized when opening 27 | # the app 28 | # If a new message comes and the move/copy/delete notification is still on hold it will be sent with the notification 29 | # for the new message. 30 | # This sets the interval to check for delayed messages. 31 | checkInterval: 20 32 | 33 | # Set the time how long notifications for not-new messages should be delayed until they are sent. 34 | # Whenever checkInterval runs, it checks if "delay" <= "waiting time" and sends the notification if the expression is 35 | # true. 36 | delay: 30 37 | 38 | # Name of the P12 encoded certificate and key in one file to be used to establish a connection to the APNS server 39 | #certificateFileP12: 40 | # Name of the PEM encoded certificate and key in one file to be used to establish a connection to the APNS server 41 | #certificateFilePem: 42 | #certificateFilePemKey: 43 | 44 | # Filename of the P8 encoded key to establish a connection to the APNS server 45 | keyFileP8: 46 | 47 | # The following options are only required for keyFile based authentication 48 | 49 | # APNS topic 50 | keyFileTopic: com.apple.mail.nil 51 | # KeyID from developer account (Certificates, Identifiers & Profiles -> Keys) 52 | keyFileKeyId: ABCDEFGH 53 | # TeamID from developer account (View Account -> Membership) 54 | keyFileTeamId: ABCDEFGH 55 | -------------------------------------------------------------------------------- /cmd/xapsd/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 | "bufio" 30 | "crypto/sha256" 31 | "encoding/hex" 32 | "flag" 33 | "fmt" 34 | "github.com/freswa/dovecot-xaps-daemon/internal" 35 | "github.com/freswa/dovecot-xaps-daemon/internal/config" 36 | "github.com/freswa/dovecot-xaps-daemon/internal/database" 37 | log "github.com/sirupsen/logrus" 38 | "os" 39 | "strings" 40 | ) 41 | 42 | const Version = "1.1" 43 | 44 | var configPath = flag.String("configPath", "", `Add an additional path to lookup the config file in`) 45 | var configName = flag.String("configName", "", `Set a different configName (without extension) than the default "xapsd"`) 46 | var generatePassword = flag.Bool("pass", false, `Generate a password hash to be used in the xapsd.yaml`) 47 | 48 | func main() { 49 | flag.Parse() 50 | if *generatePassword { 51 | hashPassword() 52 | } 53 | config.ParseConfig(*configName, *configPath) 54 | cfg := config.GetOptions() 55 | lvl, err := log.ParseLevel(cfg.LogLevel) 56 | if err != nil { 57 | log.Fatal(err) 58 | } 59 | log.SetLevel(lvl) 60 | 61 | log.Debugln("Opening databasefile at", cfg.DatabaseFile) 62 | db, err := database.NewDatabase(cfg.DatabaseFile) 63 | if err != nil { 64 | log.Fatal("Cannot open databasefile: ", err) 65 | } 66 | 67 | apns := internal.NewApns(&cfg, db) 68 | internal.NewHttpSocket(&cfg, db, apns) 69 | } 70 | 71 | // function to generate the password 72 | func hashPassword() { 73 | reader := bufio.NewReader(os.Stdin) 74 | fmt.Print("Please enter the password -> ") 75 | text, _ := reader.ReadString('\n') 76 | // remove newlines 77 | text = strings.Replace(text, "\n", "", -1) 78 | hash := sha256.New() 79 | hash.Write([]byte(text)) 80 | sha256sum := hex.EncodeToString(hash.Sum(nil)) 81 | fmt.Printf("This is the hash -> %s\n", sha256sum) 82 | fmt.Print("For security reasons, we don't fill in the hash automagically. Please do so yourself.\n") 83 | os.Exit(0) 84 | } 85 | -------------------------------------------------------------------------------- /internal/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 | log "github.com/sirupsen/logrus" 31 | "io/ioutil" 32 | "os" 33 | "sync" 34 | "time" 35 | ) 36 | 37 | var dbMutex = &sync.Mutex{} 38 | 39 | type Registration struct { 40 | DeviceToken string 41 | AccountId string 42 | } 43 | 44 | type Account struct { 45 | DeviceToken string 46 | Mailboxes []string 47 | RegistrationTime time.Time 48 | } 49 | 50 | func (account *Account) ContainsMailbox(mailbox string) bool { 51 | for _, m := range account.Mailboxes { 52 | if m == mailbox { 53 | return true 54 | } 55 | } 56 | return false 57 | } 58 | 59 | type User struct { 60 | Accounts map[string]Account 61 | } 62 | 63 | type Database struct { 64 | filename string 65 | Users map[string]User 66 | lastWrite time.Time 67 | } 68 | 69 | func NewDatabase(filename string) (*Database, error) { 70 | // check if file exists 71 | _, err := os.Stat(filename) 72 | if err != nil && os.IsNotExist(err) { 73 | db := &Database{filename: filename, Users: make(map[string]User)} 74 | err := db.write() 75 | if err != nil { 76 | return nil, err 77 | } 78 | return db, nil 79 | } 80 | 81 | data, err := ioutil.ReadFile(filename) 82 | if err != nil { 83 | return nil, err 84 | } 85 | db := Database{filename: filename, Users: make(map[string]User)} 86 | if len(data) != 0 { 87 | err := json.Unmarshal(data, &db) 88 | if err != nil { 89 | return nil, err 90 | } 91 | } 92 | 93 | registrationCleanupTicker := time.NewTicker(time.Hour * 8) 94 | go func() { 95 | for range registrationCleanupTicker.C { 96 | db.cleanupRegistered() 97 | } 98 | }() 99 | 100 | return &db, nil 101 | } 102 | 103 | func (db *Database) write() error { 104 | data, err := json.MarshalIndent(db, "", " ") 105 | if err != nil { 106 | return err 107 | } 108 | 109 | err = ioutil.WriteFile(db.filename+".new", data, 0644) 110 | return os.Rename(db.filename+".new", db.filename) 111 | } 112 | 113 | func (db *Database) AddRegistration(username, accountId, deviceToken string, mailboxes []string) (err error) { 114 | // mutual write access to database issue #16 xaps-plugin 115 | dbMutex.Lock() 116 | 117 | // Ensure the User exists 118 | if _, ok := db.Users[username]; !ok { 119 | db.Users[username] = User{Accounts: make(map[string]Account)} 120 | } else { 121 | log.Debugf("AddRegistration(): User %s already exists", username) 122 | } 123 | 124 | // Ensure the Account exists 125 | if _, ok := db.Users[username].Accounts[accountId]; !ok { 126 | db.Users[username].Accounts[accountId] = Account{} 127 | } else { 128 | log.Debugf("AddRegistration(): Account %s already exists", accountId) 129 | } 130 | 131 | // Set or update the Registration 132 | db.Users[username].Accounts[accountId] = 133 | Account{ 134 | DeviceToken: deviceToken, 135 | Mailboxes: mailboxes, 136 | RegistrationTime: time.Now(), 137 | } 138 | 139 | log.Debugf("AddRegistration(): About to flush db to disk") 140 | if db.lastWrite.Before(time.Now().Add(-time.Minute * 15)) { 141 | err = db.write() 142 | db.lastWrite = time.Now() 143 | } else { 144 | log.Debugf("AddRegistration(): DB flush postponed since last write (%s) is not older than 15 minutes", db.lastWrite) 145 | } 146 | 147 | // release mutex 148 | dbMutex.Unlock() 149 | return 150 | } 151 | 152 | func (db *Database) DeleteIfExistRegistration(reg Registration) bool { 153 | dbMutex.Lock() 154 | for username, user := range db.Users { 155 | for accountId, account := range user.Accounts { 156 | if accountId == reg.AccountId { 157 | log.Infoln("Deleting " + account.DeviceToken) 158 | delete(user.Accounts, accountId) 159 | // clean up empty users 160 | if len(user.Accounts) == 0 { 161 | delete(db.Users, username) 162 | } 163 | err := db.write() 164 | if err != nil { 165 | log.Error(err) 166 | } 167 | dbMutex.Unlock() 168 | return true 169 | } 170 | } 171 | } 172 | dbMutex.Unlock() 173 | return false 174 | } 175 | 176 | func (db *Database) FindRegistrations(username, mailbox string) ([]Registration, error) { 177 | var registrations []Registration 178 | dbMutex.Lock() 179 | if user, ok := db.Users[username]; ok { 180 | for accountId, account := range user.Accounts { 181 | if account.ContainsMailbox(mailbox) { 182 | registrations = append(registrations, 183 | Registration{DeviceToken: account.DeviceToken, AccountId: accountId}) 184 | } 185 | } 186 | } 187 | dbMutex.Unlock() 188 | return registrations, nil 189 | } 190 | 191 | func (db *Database) UserExists(username string) bool { 192 | dbMutex.Lock() 193 | _, ok := db.Users[username] 194 | dbMutex.Unlock() 195 | return ok 196 | } 197 | 198 | func (db *Database) cleanupRegistered() { 199 | log.Debugln("Check Database for devices not calling IMAP hook for more than 30d") 200 | toDelete := make([]Registration, 0) 201 | dbMutex.Lock() 202 | for _, user := range db.Users { 203 | for accountId, account := range user.Accounts { 204 | if !account.RegistrationTime.IsZero() && account.RegistrationTime.Before(time.Now().Add(-time.Hour*24*30)) { 205 | toDelete = append(toDelete, Registration{account.DeviceToken, accountId}) 206 | } 207 | } 208 | } 209 | dbMutex.Unlock() 210 | for _, reg := range toDelete { 211 | db.DeleteIfExistRegistration(reg) 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /internal/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" 30 | "io/ioutil" 31 | "log" 32 | "os" 33 | "testing" 34 | ) 35 | 36 | func DBCreateWorkingCopy() { 37 | // Open original file 38 | original, err := os.Open("testdata/database.json") 39 | if err != nil { 40 | log.Fatal(err) 41 | } 42 | defer original.Close() 43 | 44 | // Create cpy file 45 | cpy, err := os.Create("testdata/database_workingcpy.json") 46 | if err != nil { 47 | log.Fatal(err) 48 | } 49 | defer cpy.Close() 50 | 51 | //This will copy 52 | io.Copy(cpy, original) 53 | } 54 | 55 | func TestDatabase_NewDatabase(t *testing.T) { 56 | DBCreateWorkingCopy() 57 | db, err := NewDatabase("testdata/database_workingcpy.json") 58 | if err != nil { 59 | t.Error("Cannot open database testdata/database_workingcpy.json", err) 60 | } 61 | 62 | if db.filename != "testdata/database_workingcpy.json" { 63 | t.Error(`db.filename != "testdata/database_workingcpy.json"`) 64 | } 65 | 66 | if len(db.Users) != 2 { 67 | t.Error("len(db.Users) != 2") 68 | } 69 | 70 | if len(db.Users["stefan"].Accounts) != 2 { 71 | t.Error(`db.Users["stefan"].Accounts) != 1`) 72 | } 73 | 74 | if len(db.Users["alice"].Accounts) != 1 { 75 | t.Error(`db.Users["alice"].Accounts) != 1`) 76 | } 77 | } 78 | 79 | func TestDatabase_AddRegistration(t *testing.T) { 80 | f, err := ioutil.TempFile("/tmp", "database_test_Test_addRegistration") 81 | if err != nil { 82 | t.Error("Can't create temporary file", err) 83 | } 84 | defer os.Remove(f.Name()) 85 | 86 | db, err := NewDatabase(f.Name()) 87 | if err != nil { 88 | t.Error("Cannot open database", err) 89 | } 90 | 91 | if err := db.AddRegistration("test@example.com", "testaccountid1", "testtoken1", []string{"Inbox", "Spam"}); err != nil { 92 | t.Error("Cannot addRegistration:", err) 93 | } 94 | 95 | if err := db.AddRegistration("test@example.com", "testaccountid2", "testtoken2", []string{"Inbox", "Ham"}); err != nil { 96 | t.Error("Cannot addRegistration:", err) 97 | } 98 | 99 | if err := db.AddRegistration("alice@example.com", "aliceaccountid", "alicetoken", []string{"Inbox", "Important"}); err != nil { 100 | t.Error("Cannot addRegistration:", err) 101 | } 102 | db.write() 103 | 104 | db, err = NewDatabase(f.Name()) 105 | if err != nil { 106 | t.Error("Cannot open database", err) 107 | } 108 | 109 | if _, ok := db.Users["test@example.com"]; !ok { 110 | t.Error(`Cannot find Users["test@example.com"]`) 111 | } 112 | 113 | if _, ok := db.Users["alice@example.com"]; !ok { 114 | t.Error(`Cannot find Users["test@example.com"]`) 115 | } 116 | } 117 | 118 | func TestDatabase_FindRegistrations(t *testing.T) { 119 | DBCreateWorkingCopy() 120 | db, err := NewDatabase("testdata/database_workingcpy.json") 121 | if err != nil { 122 | t.Error("Cannot open database testdata/database_workingcpy.json", err) 123 | } 124 | 125 | registrations, err := db.FindRegistrations("stefan", "Inbox") 126 | if err != nil { 127 | t.Error("Cannot findRegistrations:", err) 128 | } 129 | 130 | if len(registrations) != 2 { 131 | t.Error(`len(registrations) != 2`) 132 | } 133 | 134 | registrations, err = db.FindRegistrations("stefan", "Ham") 135 | if err != nil { 136 | t.Error("Cannot findRegistrations:", err) 137 | } 138 | 139 | if len(registrations) != 1 { 140 | t.Error(`len(registrations) != 1`) 141 | } 142 | 143 | registrations, err = db.FindRegistrations("doesnotexist", "Inbox") 144 | if err != nil { 145 | t.Error("Cannot findRegistrations:", err) 146 | } 147 | 148 | if len(registrations) != 0 { 149 | t.Error(`len(registrations) != 0`) 150 | } 151 | } 152 | 153 | func TestDatabase_AccountContainsMailbox(t *testing.T) { 154 | account := Account{DeviceToken: "SomeToken", Mailboxes: []string{"Inbox", "Ham"}} 155 | 156 | if account.ContainsMailbox("Inbox") != true { 157 | t.Error(`account.ContainsMailbox("Inbox") != true`) 158 | } 159 | 160 | if account.ContainsMailbox("Ham") != true { 161 | t.Error(`account.ContainsMailbox("Ham") != true`) 162 | } 163 | 164 | if account.ContainsMailbox("Cheese") != false { 165 | t.Error(`account.ContainsMailbox("Cheese") != false`) 166 | } 167 | } 168 | 169 | func TestDatabase_DeleteIfExistRegistration(t *testing.T) { 170 | DBCreateWorkingCopy() 171 | db, err := NewDatabase("testdata/database_workingcpy.json") 172 | if err != nil { 173 | t.Error("Cannot open database testdata/database_workingcpy.json", err) 174 | } 175 | 176 | success := db.DeleteIfExistRegistration(Registration{ 177 | DeviceToken: "alicedevicetoken1", 178 | AccountId: "aliceaccountid1", 179 | }) 180 | if !success { 181 | t.Error("Device token could not be removed", err) 182 | } 183 | 184 | success = db.DeleteIfExistRegistration(Registration{ 185 | DeviceToken: "alicedevicetoken1", 186 | AccountId: "aliceaccountid1", 187 | }) 188 | if success { 189 | t.Error("Not existend device token has been *successfully* deleted???", err) 190 | } 191 | } 192 | 193 | func TestDatabase_CleanupRegistration(t *testing.T) { 194 | DBCreateWorkingCopy() 195 | db, err := NewDatabase("testdata/database_workingcpy.json") 196 | if err != nil { 197 | t.Error("Cannot open database testdata/database_workingcpy.json", err) 198 | } 199 | 200 | arr, _ := db.FindRegistrations("alice", "Inbox") 201 | if len(arr) < 1 { 202 | t.Error("Registration to cleanup not found!") 203 | } 204 | 205 | db.cleanupRegistered() 206 | 207 | arr, _ = db.FindRegistrations("alice", "Inbox") 208 | if len(arr) > 0 { 209 | t.Error("Registration not cleaned up!") 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= 2 | github.com/alecthomas/units v0.0.0-20201120081800-1786d5ef83d4/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= 3 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 5 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 7 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 8 | github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= 9 | github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 10 | github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= 11 | github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 12 | github.com/golang-jwt/jwt/v4 v4.4.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= 13 | github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= 14 | github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= 15 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 16 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 17 | github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= 18 | github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= 19 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 20 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 21 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 22 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 23 | github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= 24 | github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 25 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 26 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 27 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 28 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 29 | github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k= 30 | github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk= 31 | github.com/sideshow/apns2 v0.25.0 h1:XOzanncO9MQxkb03T/2uU2KcdVjYiIf0TMLzec0FTW4= 32 | github.com/sideshow/apns2 v0.25.0/go.mod h1:7Fceu+sL0XscxrfLSkAoH6UtvKefq3Kq1n4W3ayQZqE= 33 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 34 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 35 | github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= 36 | github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= 37 | github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA= 38 | github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo= 39 | github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE= 40 | github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= 41 | github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= 42 | github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 43 | github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= 44 | github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= 45 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 46 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 47 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 48 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 49 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 50 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 51 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 52 | go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 53 | go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 54 | golang.org/x/crypto v0.0.0-20170512130425-ab89591268e0/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 55 | golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= 56 | golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= 57 | golang.org/x/net v0.0.0-20220403103023-749bd193bc2b/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= 58 | golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= 59 | golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= 60 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 61 | golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 62 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 63 | golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= 64 | golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 65 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 66 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 67 | golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= 68 | golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= 69 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 70 | gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= 71 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 72 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 73 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 74 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 75 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 76 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 77 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 78 | -------------------------------------------------------------------------------- /internal/socket.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "strings" 7 | 8 | "github.com/freswa/dovecot-xaps-daemon/internal/config" 9 | "github.com/freswa/dovecot-xaps-daemon/internal/database" 10 | "github.com/julienschmidt/httprouter" 11 | log "github.com/sirupsen/logrus" 12 | ) 13 | 14 | type httpHandler struct { 15 | db *database.Database 16 | apns *Apns 17 | } 18 | 19 | // Register struct to handle register requests via IMAP like: 20 | // 21 | // REGISTER aps-account-id="AAA" aps-device-token="BBB" 22 | // aps-subtopic="com.apple.mobilemail" 23 | // dovecot-username="stefan" 24 | // dovecot-mailboxes=("Inbox","Notes") 25 | type Register struct { 26 | ApsAccountId string 27 | ApsDeviceToken string 28 | ApsSubtopic string 29 | Username string 30 | Mailboxes []string 31 | } 32 | 33 | // NOTIFY dovecot-username="stefan" dovecot-mailbox="Inbox" 34 | type Notify struct { 35 | Username string 36 | Mailbox string 37 | Events []string 38 | } 39 | 40 | func NewHttpSocket(config *config.Config, db *database.Database, apns *Apns) { 41 | router := httprouter.New() 42 | httpSocket := httpHandler{db, apns} 43 | router.POST("/register", httpSocket.handleRegister) 44 | router.POST("/notify", httpSocket.handleNotify) 45 | if len(config.TlsCertfile) > 0 || len(config.TlsKeyfile) > 0 { 46 | go func() { 47 | err := http.ListenAndServeTLS(config.TlsListenAddr+":"+config.TlsPort, config.TlsCertfile, config.TlsKeyfile, router) 48 | if err != nil { 49 | log.Fatalf("Could not listen on address %s:%s: %s", config.TlsListenAddr, config.TlsPort, err) 50 | } 51 | }() 52 | } 53 | err := http.ListenAndServe(config.ListenAddr+":"+config.Port, router) 54 | if err != nil { 55 | log.Fatalf("Could not listen on address %s:%s: %s", config.ListenAddr, config.Port, err) 56 | } 57 | } 58 | 59 | // Handle the REGISTER command. It looks as follows: 60 | // 61 | // REGISTER aps-account-id="AAA" aps-device-token="BBB" 62 | // aps-subtopic="com.apple.mobilemail" 63 | // dovecot-username="stefan" 64 | // dovecot-mailboxes=("Inbox","Notes") 65 | // 66 | // The command returns the aps-topic, which is the common name of 67 | // the certificate issued by OS X Server for email push 68 | // notifications. 69 | func (httpHandler *httpHandler) handleRegister(writer http.ResponseWriter, request *http.Request, _ httprouter.Params) { 70 | defer request.Body.Close() 71 | 72 | reg := Register{} 73 | 74 | err := json.NewDecoder(request.Body).Decode(®) 75 | if err != nil { 76 | log.Errorf("Error while handling register call: %s", err) 77 | writer.WriteHeader(http.StatusBadRequest) 78 | return 79 | } 80 | 81 | log.Debugf("Received Registration: %s", reg) 82 | 83 | if reg.checkParams() { 84 | log.Errorf("Incomplete register payload: %v", reg) 85 | writer.WriteHeader(http.StatusBadRequest) 86 | return 87 | } 88 | 89 | // Make sure the subtopic is ok 90 | if reg.ApsSubtopic != "com.apple.mobilemail" { 91 | log.Errorf("Unknown aps-subtopic: %s", reg.ApsSubtopic) 92 | writer.WriteHeader(http.StatusBadRequest) 93 | return 94 | } 95 | 96 | // Register this email/account-id/device-token combination 97 | err = httpHandler.db.AddRegistration(strings.ToLower(reg.Username), reg.ApsAccountId, reg.ApsDeviceToken, reg.Mailboxes) 98 | if err != nil { 99 | log.Errorf("Failed to register client:: %s", err) 100 | writer.WriteHeader(http.StatusInternalServerError) 101 | return 102 | } 103 | 104 | log.Debugf("handle() Register replying to dovecot plugin with: %s", httpHandler.apns.Topic) 105 | 106 | writer.Write([]byte(httpHandler.apns.Topic)) 107 | } 108 | 109 | // Handle the NOTIFY command. It looks as follows: 110 | // 111 | // NOTIFY dovecot-username="stefan" dovecot-mailbox="Inbox" 112 | // 113 | // See if the the username has devices registered. If it has, loop 114 | // over them to find the ones that are interested in the named 115 | // mailbox and send those a push notificiation. 116 | // 117 | // The push notification looks like this: 118 | // 119 | // { "aps": { "account-id": aps-account-id } } 120 | func (httpHandler *httpHandler) handleNotify(writer http.ResponseWriter, request *http.Request, _ httprouter.Params) { 121 | defer request.Body.Close() 122 | 123 | notify := Notify{} 124 | err := json.NewDecoder(request.Body).Decode(¬ify) 125 | if err != nil { 126 | log.Errorf("Error while handling notify call: %s", err) 127 | writer.WriteHeader(http.StatusBadRequest) 128 | return 129 | } 130 | 131 | if notify.checkParams() { 132 | log.Errorf("Incomplete register payload: %v", notify) 133 | writer.WriteHeader(http.StatusBadRequest) 134 | return 135 | } 136 | 137 | // Dovecot supports case sensitive usernames, but postfix doesn't 138 | // So we only care about lowercase users for now 139 | // This isn't an exploit either, since we don't send any email contents via push 140 | notify.Username = strings.ToLower(notify.Username) 141 | 142 | isMessageNew := false 143 | // check if this is an event for a new message 144 | // for all possible events have a look at dovecot-core: 145 | // grep '#define EVENT_NAME' src/plugins/push-notification/push-notification-event* 146 | for _, e := range notify.Events { 147 | if e == "MessageNew" { 148 | isMessageNew = true 149 | } 150 | } 151 | 152 | // we don't know how to handle other mailboxes other than INBOX, so ignore them 153 | if notify.Mailbox != "INBOX" { 154 | log.Debugln("Ignoring non INBOX event for:", notify.Mailbox) 155 | writer.WriteHeader(http.StatusOK) 156 | return 157 | } 158 | 159 | // Find all the devices registered for this mailbox event 160 | registrations, err := httpHandler.db.FindRegistrations(notify.Username, notify.Mailbox) 161 | if err != nil { 162 | log.Errorf("Cannot lookup registrations: %s", err) 163 | writer.WriteHeader(http.StatusInternalServerError) 164 | return 165 | } 166 | 167 | for _, r := range registrations { 168 | log.Debugf("Found registration %s with token %s for username: %s", r.AccountId, r.DeviceToken, notify.Username) 169 | } 170 | if len(registrations) == 0 { 171 | if httpHandler.db.UserExists(notify.Username) { 172 | // This isn't an error as registrations are also empty if the mailbox doesn't match 173 | log.Infof("No registered mailbox found for username: %s", notify.Username) 174 | writer.WriteHeader(http.StatusNoContent) 175 | } else { 176 | log.Warnf("No registration found for username: %s", notify.Username) 177 | writer.WriteHeader(http.StatusNotFound) 178 | } 179 | return 180 | } 181 | 182 | // Send a notification to all registered devices. We ignore failures 183 | // because there is not a lot we can do. 184 | for _, registration := range registrations { 185 | httpHandler.apns.SendNotification(registration, !isMessageNew) 186 | } 187 | 188 | writer.WriteHeader(http.StatusOK) 189 | } 190 | 191 | func (reg *Register) checkParams() (isError bool) { 192 | // Make sure we got the required parameters 193 | if len(reg.ApsAccountId) == 0 { 194 | log.Error("Missing aps-account-id in register request") 195 | isError = true 196 | } 197 | if len(reg.ApsDeviceToken) == 0 { 198 | log.Error("Missing aps-device-token in register request") 199 | isError = true 200 | } 201 | if len(reg.Username) == 0 { 202 | log.Error("Missing dovecot-username in register request") 203 | isError = true 204 | } 205 | if len(reg.Mailboxes) == 0 { 206 | log.Error("Missing dovecot-mailboxes in register request") 207 | isError = true 208 | } 209 | return 210 | } 211 | 212 | func (notify *Notify) checkParams() (isError bool) { 213 | // Make sure we got the required parameters 214 | if len(notify.Username) == 0 { 215 | log.Error("Missing username in notify request") 216 | isError = true 217 | } 218 | if len(notify.Mailbox) == 0 { 219 | log.Error("Missing mailbox in notify request") 220 | isError = true 221 | } 222 | if len(notify.Events) == 0 { 223 | log.Error("Missing register in notify request") 224 | isError = true 225 | } 226 | return 227 | } 228 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![YourActionName Actions Status](https://github.com/freswa/dovecot-xaps-daemon/workflows/Test/badge.svg)](https://github.com/freswa/dovecot-xaps-daemon/actions) 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/freswa/dovecot-xaps-plugin) project, will 10 | enable push email for iOS devices that talk to your Dovecot 2.x IMAP server. This is specially useful for people who 11 | are migrating away from running email services on OS X Server and want to keep the Push Email ability. 12 | 13 | > Please note that it is not possible to use this project without legally owning a copy of OS X Server. You can purchase 14 | > OS X Server on the [Mac App Store](https://itunes.apple.com/ca/app/os-x-server/id714547929?mt=12) or download it for 15 | > free if you are a registered Mac or iOS developer. 16 | 17 | What is the advantage of push? 18 | ------------------------------ 19 | 20 | Without Push, your iPhone will Fetch email periodically. This means that mail is delayed and that your device will use 21 | precious power to connect to a remote IMAP server. 22 | 23 | Using native Push messages means that your phone will receive new email notifications over a highly optimized connection 24 | to iCloud that it already has for other purposes. 25 | 26 | High Level Overview 27 | ------------------- 28 | 29 | There are two parts to enabling iOS Push Email. You will need both parts for this to work. 30 | 31 | First you need to install the Dovecot plugins from the [dovecot-xaps-plugin](https://github.com/freswa/dovecot-xaps-plugin) 32 | project. How do to that is documented in the README file in that project. The Dovecot plugin adds support for the `XAPPLEPUSHSERVICE` 33 | IMAP extension that will let iOS devices register themselves to receive native push notifications for new email arrival. 34 | 35 | (Apple did not document this feature, but it did publish the source code for all their Dovecot patches on the 36 | [Apple Open Source project site](http://www.opensource.apple.com/source/dovecot/dovecot-293/), which include this feature. 37 | Although I was not able to follow a specification, I was able to read their open source project and do a clean implementation 38 | with all original code.) 39 | 40 | Second, you need to install a daemon process (contained in this project) that will be responsible for receiving new email 41 | notifications from the Dovecot Local Delivery Agent and transforming those into native Apple Push Notifications. 42 | 43 | Installation 44 | ============ 45 | 46 | Prerequisites 47 | ------------- 48 | 49 | You are going to need the following things to get this going: 50 | 51 | * Some patience and willingness to experiment - Although I run this project in production, it may contain bugs. 52 | * Apple ID you’ve purchased macOS Server with 53 | * Dovecot > 2.2.19 (which introduced the push-notification plugin) 54 | * Go version 1.19.7 (see [issue #24](https://github.com/freswa/dovecot-xaps-daemon/issues/24)) 55 | 56 | Compiling and Installing the Daemon 57 | ----------------------------------- 58 | 59 | The daemon is written in Go. The easiest way to build it is with go itself. 60 | 61 | ``` 62 | git clone https://github.com/freswa/dovecot-xaps-daemon.git 63 | cd dovecot-xaps-daemon 64 | wget https://go.dev/dl/go1.19.7.linux-amd64.tar.gz 65 | tar zxvf go1.19.7.linux-amd64.tar.gz 66 | go/bin/go build ./cmd/xapsd/xapsd.go 67 | ``` 68 | 69 | Running the Daemon 70 | ------------------ 71 | 72 | We assume that the daemon is installed in `/usr/bin/xapsd`. 73 | 74 | * Create a system user `xapsd` in group `xapsd`. 75 | * Create `/var/lib/xapsd` owned by user `xapsd`, group `xapsd`. 76 | * Use the systemd file from `configs/systemd/xapsd.service` to run the daemon. 77 | On Debian-like distributions, place it in `/etc/systemd/system`. 78 | * The config file from `configs/xapsd/xapsd.yaml` has to go into the directory `/etc/xapsd`. 79 | Change config to fit your needs. 80 | Especially fill in the details of the Apple ID. 81 | The parameter `appleId` must be set to the login email address of the account. 82 | Please do _NOT_ fill in your password into `appleIdHashedPassword`, but instead run 83 | `xapsd -pass`. Then copy the printed hash to the config file. 84 | * Start the xapsd service using `systemctl start xapsd`, and restart dovecot. 85 | * Watch the system logs for errors. 86 | * If everything is working, enable the xapsd service to start automatically on reboot (`systemctl enable xapsd`). 87 | 88 | You don't have to care about certificate generation and renewal, as this is handled by the daemon. 89 | 90 | On first run, the system log should contain information similar to the following: 91 | 92 | ``` 93 | xapsd[33391]: time="2023-04-29T12:11:02-05:00" level=info msg="Certificate valid until 2024-04-28 17:01:00 +0000 UTC" 94 | ``` 95 | 96 | 97 | Setting up Devices 98 | ------------------ 99 | 100 | Your iOS devices will discover that the server supports Push automatically the first time they connect. 101 | To force them to reconnect you can reboot the iOS device or turn Airport Mode on and off with a little delay in between. 102 | 103 | If you go to your Email settings, you should see that the account has switched to Push. 104 | 105 | The mail log will contain information such as 106 | 107 | ``` 108 | dovecot[4931]: imap(user)<276831>: Debug: Sending registration: {"ApsAccountId":"...","ApsDeviceToken":"...","ApsSubtopic":"com.apple.mobilemail","Username":"user","Mailboxes": ["Open Orders","INBOX","Sent Messages"]} 109 | dovecot[4931]: imap(user)<276831>: Debug: Notification sent successfully: 200 OK 110 | ``` 111 | 112 | ## Troubleshooting 113 | 114 | * `Error: net_connect_unix(/run/dovecot/xapsd.sock) failed: Connection refused` 115 | Ensure the [dovecot-xaps-plugin](https://github.com/freswa/dovecot-xaps-plugin) is installed correctly. 116 | This version of the xapsd daemon does not work with older versions of the plugin, or plugins from other repositories. 117 | * Multiple devices with same user name, same account, and only a difference in device token – only one device will get notifications 118 | This can happen when an iOS device is “cloned” (such as old iPhone to new iPhone). 119 | You must delete and re-create the mail account on one device. 120 | Watch the contents of `/var/lib/xapsd/database.json ` to see which devices are registered and will receive notifications. 121 | The `xapsd` service may require a restart to flush out an unwanted device. 122 | * `Post "https://identity.apple.com/pushcert/caservice/new": net/http: HTTP/1.x transport connection broken: malformed MIME header line: 1;: mode=block` 123 | This can happen when go 1.20 is used to build the daemon. 124 | This error can cause the daemon to keep registering with Apple, creating lots of new certificates. 125 | 126 | Privacy 127 | ------- 128 | 129 | Each time a message is received, dovecot-xaps-daemon sends Apple a TLS-secured HTTP/2 request, which Apple uses to 130 | send a notification over a persistent connection maintained to between the user's device and Apple's push notification 131 | servers. 132 | 133 | The request contains the following information: a device token (used by Apple to identify which device should be sent 134 | a push notification), an account ID (used by the user's device to identify which account it should poll for new messages), 135 | and a certificate topic. The certificate topic identifies the server to Apple and is hardcoded in the certificate issued 136 | by Apple and setup in the configuration for dovecot-xaps-daemon. `device token` and `account ID` are terms special to 137 | the native Mail.app of iOS and not available by any other app. 138 | 139 | By virtue of having made the request, Apple also learns the IP address of the server sending the push notification, and 140 | the time at which the push notification is sent by the server to Apple. 141 | 142 | While no information typically thought of as private is directly exposed to Apple, some difficult to avoid leaks still occur. 143 | For example, Apple could correlate that two or more users frequently receive a push notification at almost the exact same time. 144 | 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. 145 | -------------------------------------------------------------------------------- /internal/apns.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "crypto/tls" 5 | "crypto/x509" 6 | "errors" 7 | "io/ioutil" 8 | "net/http" 9 | "sync" 10 | "time" 11 | 12 | "github.com/freswa/dovecot-xaps-daemon/internal/config" 13 | "github.com/freswa/dovecot-xaps-daemon/internal/database" 14 | "github.com/sideshow/apns2" 15 | "github.com/sideshow/apns2/certificate" 16 | "github.com/sideshow/apns2/token" 17 | log "github.com/sirupsen/logrus" 18 | ) 19 | 20 | const ( 21 | // renew certs this duration before the certs become invalid 22 | renewTimeBuffer = time.Hour * 24 * 30 23 | ) 24 | 25 | var ( 26 | oidUid = []int{0, 9, 2342, 19200300, 100, 1, 1} 27 | productionOID = []int{1, 2, 840, 113635, 100, 6, 3, 2} 28 | //GeoTrustCert = "-----BEGIN CERTIFICATE-----\nMIIDVDCCAjygAwIBAgIDAjRWMA0GCSqGSIb3DQEBBQUAMEIxCzAJBgNVBAYTAlVT\nMRYwFAYDVQQKEw1HZW9UcnVzdCBJbmMuMRswGQYDVQQDExJHZW9UcnVzdCBHbG9i\nYWwgQ0EwHhcNMDIwNTIxMDQwMDAwWhcNMjIwNTIxMDQwMDAwWjBCMQswCQYDVQQG\nEwJVUzEWMBQGA1UEChMNR2VvVHJ1c3QgSW5jLjEbMBkGA1UEAxMSR2VvVHJ1c3Qg\nR2xvYmFsIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2swYYzD9\n9BcjGlZ+W988bDjkcbd4kdS8odhM+KhDtgPpTSEHCIjaWC9mOSm9BXiLnTjoBbdq\nfnGk5sRgprDvgOSJKA+eJdbtg/OtppHHmMlCGDUUna2YRpIuT8rxh0PBFpVXLVDv\niS2Aelet8u5fa9IAjbkU+BQVNdnARqN7csiRv8lVK83Qlz6cJmTM386DGXHKTubU\n1XupGc1V3sjs0l44U+VcT4wt/lAjNvxm5suOpDkZALeVAjmRCw7+OC7RHQWa9k0+\nbw8HHa8sHo9gOeL6NlMTOdReJivbPagUvTLrGAMoUgRx5aszPeE4uwc2hGKceeoW\nMPRfwCvocWvk+QIDAQABo1MwUTAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTA\nephojYn7qwVkDBF9qn1luMrMTjAfBgNVHSMEGDAWgBTAephojYn7qwVkDBF9qn1l\nuMrMTjANBgkqhkiG9w0BAQUFAAOCAQEANeMpauUvXVSOKVCUn5kaFOSPeCpilKIn\nZ57QzxpeR+nBsqTP3UEaBU6bS+5Kb1VSsyShNwrrZHYqLizz/Tt1kL/6cdjHPTfS\ntQWVYrmm3ok9Nns4d0iXrKYgjy6myQzCsplFAMfOEVEiIuCl6rYVSAlk6l5PdPcF\nPseKUgzbFbS9bZvlxrFUaKnjaZC2mqUPuLk/IH2uSrW4nOQdtqvmlKXBx4Ot2/Un\nhw4EbNX/3aBd7YdStysVAq45pmp06drE57xNNB6pXE0zX5IJL4hmXXeXxx12E6nV\n5fEWCRE11azbJHFwLJhWC9kXtNHjUStedejV0NxPNO3CBWaAocvmMw==\n-----END CERTIFICATE-----" 29 | ) 30 | 31 | type Apns struct { 32 | DelayTime uint 33 | Topic string 34 | CheckDelayedInterval uint 35 | client *apns2.Client 36 | db *database.Database 37 | mapMutex sync.Mutex 38 | delayedApns map[database.Registration]time.Time 39 | RenewTimer *time.Timer 40 | } 41 | 42 | func NewApns(cfg *config.Config, db *database.Database) (apns *Apns) { 43 | apns = &Apns{ 44 | DelayTime: cfg.Delay, 45 | CheckDelayedInterval: cfg.CheckInterval, 46 | db: db, 47 | mapMutex: sync.Mutex{}, 48 | delayedApns: make(map[database.Registration]time.Time), 49 | } 50 | log.Debugln("APNS for non NewMessage events will be delayed for", time.Second*time.Duration(apns.DelayTime)) 51 | 52 | if cfg.CertificateFileP12 != "" { 53 | log.Debugf("Loading Certificate at %s", "/etc/xapsd/"+cfg.CertificateFileP12) 54 | cert, err := certificate.FromP12File("/etc/xapsd/"+cfg.CertificateFileP12, "") 55 | if err != nil { 56 | log.Fatal("Cert Error:", err) 57 | } 58 | topic, err := topicFromCertificate(cert) 59 | if err != nil { 60 | log.Fatalln("Could not parse apns topic from certificate: ", err) 61 | } 62 | apns.Topic = topic 63 | apns.client = apns2.NewClient(cert).Production() 64 | } else if cfg.CertificateFilePem != "" { 65 | log.Debugf("Loading Certificate at %s", "/etc/xapsd/"+cfg.CertificateFilePem) 66 | certData, err := ioutil.ReadFile("/etc/xapsd/" + cfg.CertificateFilePem) 67 | if err != nil { 68 | log.Fatal("Cert Error:", err) 69 | } 70 | keyData, err := ioutil.ReadFile("/etc/xapsd/" + cfg.CertificateFilePemKey) 71 | if err != nil { 72 | log.Fatal("Key Error:", err) 73 | } 74 | cert, err := tls.X509KeyPair(certData, keyData) 75 | if err != nil { 76 | log.Fatal("Cert Error:", err) 77 | } 78 | topic, err := topicFromCertificate(cert) 79 | if err != nil { 80 | log.Fatalln("Could not parse apns topic from certificate: ", err) 81 | } 82 | apns.Topic = topic 83 | apns.client = apns2.NewClient(cert).Production() 84 | } else { 85 | if cfg.KeyFileKeyId == "" { 86 | log.Fatalln(errors.New("No KeyFileKeyId found")) 87 | } 88 | if cfg.KeyFileTeamId == "" { 89 | log.Fatalln(errors.New("No KeyFileTeamId found")) 90 | } 91 | if cfg.KeyFileTopic == "" { 92 | log.Fatalln(errors.New("No KeyFileTopic found")) 93 | } 94 | log.Debugln("Loading Keyfile") 95 | authKey, err := token.AuthKeyFromFile("/etc/xapsd/" + cfg.KeyFileP8) 96 | if err != nil { 97 | log.Fatal("Token error:", err) 98 | } 99 | apnsToken := &token.Token{ 100 | AuthKey: authKey, 101 | KeyID: cfg.KeyFileKeyId, 102 | // TeamID from developer account (View Account -> Membership) 103 | TeamID: cfg.KeyFileTeamId, 104 | } 105 | apns.Topic = cfg.KeyFileTopic 106 | apns.client = apns2.NewTokenClient(apnsToken).Production() 107 | } 108 | log.Debugln("Topic is", apns.Topic) 109 | 110 | // Get the SystemCertPool, continue with an empty pool on error 111 | //rootCAs, _ := x509.SystemCertPool() 112 | //if rootCAs == nil { 113 | // rootCAs = x509.NewCertPool() 114 | //} 115 | // 116 | //// Append our cert to the system pool 117 | //if ok := rootCAs.AppendCertsFromPEM([]byte(GeoTrustCert)); !ok { 118 | // log.Infoln("No certs appended, using system certs only") 119 | //} 120 | //apns.client.HTTPClient.Transport.(*http2.Transport).TLSClientConfig.RootCAs = rootCAs 121 | 122 | apns.createDelayedNotificationThread() 123 | return apns 124 | } 125 | 126 | func (apns *Apns) createDelayedNotificationThread() { 127 | delayedNotificationTicker := time.NewTicker(time.Second * time.Duration(apns.CheckDelayedInterval)) 128 | go func() { 129 | for range delayedNotificationTicker.C { 130 | apns.checkDelayed() 131 | } 132 | }() 133 | } 134 | 135 | func (apns *Apns) checkDelayed() { 136 | log.Debugln("Checking all delayed APNS") 137 | var sendNow []database.Registration 138 | apns.mapMutex.Lock() 139 | for reg, t := range apns.delayedApns { 140 | log.Debugln("Registration", reg.AccountId, "/", reg.DeviceToken, "has been waiting for", time.Since(t)) 141 | if time.Since(t) > time.Second*time.Duration(apns.DelayTime) { 142 | sendNow = append(sendNow, reg) 143 | delete(apns.delayedApns, reg) 144 | } 145 | } 146 | apns.mapMutex.Unlock() 147 | for _, reg := range sendNow { 148 | apns.SendNotification(reg, false) 149 | } 150 | } 151 | 152 | func (apns *Apns) SendNotification(registration database.Registration, delayed bool) { 153 | apns.mapMutex.Lock() 154 | if delayed { 155 | apns.delayedApns[registration] = time.Now() 156 | apns.mapMutex.Unlock() 157 | return 158 | } else { 159 | delete(apns.delayedApns, registration) 160 | apns.mapMutex.Unlock() 161 | } 162 | log.Debugln("Sending notification to", registration.AccountId, "/", registration.DeviceToken) 163 | 164 | notification := &apns2.Notification{} 165 | notification.DeviceToken = registration.DeviceToken 166 | notification.Topic = apns.Topic 167 | composedPayload := []byte(`{"aps":{`) 168 | composedPayload = append(composedPayload, []byte(`"account-id":"`+registration.AccountId+`"`)...) 169 | composedPayload = append(composedPayload, []byte(`}}`)...) 170 | notification.Payload = composedPayload 171 | notification.PushType = apns2.PushTypeBackground 172 | notification.Expiration = time.Now().Add(24 * time.Hour) 173 | // set the apns-priority 174 | //notification.Priority = apns2.PriorityLow 175 | 176 | if log.IsLevelEnabled(log.DebugLevel) { 177 | dbgstr, _ := notification.MarshalJSON() 178 | log.Debugf("Sending: %s", dbgstr) 179 | } 180 | res, err := apns.client.Push(notification) 181 | 182 | if err != nil { 183 | log.Fatal("Error:", err) 184 | } 185 | 186 | switch res.StatusCode { 187 | case http.StatusOK: 188 | log.Debugln("Apple returned 200 for notification to", registration.AccountId, "/", registration.DeviceToken) 189 | case 410: 190 | // The device token is inactive for the specified topic. 191 | log.Infoln("Apple returned 410 for notification to", registration.AccountId, "/", registration.DeviceToken) 192 | apns.db.DeleteIfExistRegistration(registration) 193 | default: 194 | log.Errorf("Apple returned a non-200 HTTP status: %v %v %v\n", res.StatusCode, res.ApnsID, res.Reason) 195 | } 196 | } 197 | 198 | func topicFromCertificate(tlsCert tls.Certificate) (string, error) { 199 | if len(tlsCert.Certificate) > 1 { 200 | return "", errors.New("found multiple certificates in the cert file - only one is allowed") 201 | } 202 | 203 | cert, err := x509.ParseCertificate(tlsCert.Certificate[0]) 204 | if err != nil { 205 | log.Fatalln("Could not parse certificate: ", err) 206 | } 207 | 208 | if len(cert.Subject.Names) == 0 { 209 | return "", errors.New("Subject.Names is empty") 210 | } 211 | 212 | if !cert.Subject.Names[0].Type.Equal(oidUid) { 213 | return "", errors.New("did not find a Subject.Names[0] with type 0.9.2342.19200300.100.1.1") 214 | } 215 | 216 | return cert.Subject.Names[0].Value.(string), nil 217 | } 218 | --------------------------------------------------------------------------------