├── .codecov.yml ├── .gitignore ├── .golangci.yml ├── .travis.yml ├── LICENSE ├── README.md ├── backend.go ├── bodystructure_easyjson.go ├── cached_header_easyjson.go ├── cmd ├── imapd │ └── main.go └── imapsql-ctl │ ├── README.md │ ├── appendlimit.go │ ├── flags.go │ ├── main.go │ ├── mboxes.go │ ├── msgs.go │ ├── mysql.go │ ├── postgresql.go │ ├── sqlite3.go │ ├── termios.go │ ├── termios_stub.go │ ├── users.go │ └── utils.go ├── compress.go ├── date.go ├── dbhacks.go ├── delivery.go ├── delivery_test.go ├── envelope.go ├── errors.go ├── errors_noncgo.go ├── external_store.go ├── fetch.go ├── flags.go ├── fsstore.go ├── fsstore_test.go ├── go.mod ├── go.sum ├── imaptest.md ├── log.go ├── lz4_test.go ├── mailbox.go ├── reference_counter_test.go ├── regress_test.go ├── schema.go ├── search.go ├── sortthread.go ├── sql.go ├── sql_fetch.go ├── sql_flags.go ├── sql_search.go ├── user.go └── user_test.go /.codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | target: auto 6 | threshold: 5 7 | base: auto 8 | # advanced 9 | branches: null 10 | if_no_uploads: error 11 | if_not_found: success 12 | if_ci_failed: error 13 | only_pulls: false 14 | flags: null 15 | paths: null 16 | patch: off 17 | comment: 18 | layout: "diff, files" 19 | behavior: default 20 | require_changes: false # if true: only post the comment if coverage changes 21 | require_base: no # [yes :: must have a base report to post] 22 | require_head: yes # [yes :: must have a head report to post] 23 | branches: null # branch names that can post comment 24 | 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | cmd/imapsql-ctl/imapsql-ctl 2 | cmd/imapd/imapd 3 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters: 2 | enable: 3 | - gosimple 4 | - structcheck 5 | - varcheck 6 | - errcheck 7 | - staticcheck 8 | - ineffassign 9 | - deadcode 10 | - typecheck 11 | - govet 12 | - unused 13 | - scopelint 14 | - goimports 15 | - prealloc 16 | - unconvert 17 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | dist: xenial 3 | language: go 4 | 5 | cache: 6 | directories: 7 | - /home/travis/gopath/pkg/linux_amd64 8 | - /home/travis/gopath/pkg/mod 9 | 10 | go: 11 | - "1.11.4" 12 | 13 | matrix: 14 | include: 15 | # this build job is catch-all for "different" test conditions 16 | - go: "1.x" 17 | env: TEST_DB=sqlite3 TEST_DSN=":memory:" GO111MODULE=on SHUFFLE_CASES=1 PARALLEL_TESTS=2 18 | script: 19 | - go test ./... -race -coverprofile=coverage.txt -covermode=atomic -tags $TEST_DB -count 8 -p $PARALLEL_TESTS -ldflags '-X github.com/foxcpp/go-imap-sql.defaultPassHashAlgo=sha3-512' 20 | - go test ./... -v -run 'TestBackend/User.*' -coverprofile=coverage-users.txt -covermode=atomic -tags $TEST_DB 21 | - env: TEST_DB=sqlite3 TEST_DSN=":memory:" GO111MODULE=on SHUFFLE_CASES=1 PARALLEL_TESTS=2 22 | - env: TEST_DB=postgres TEST_DSN="user=postgres dbname=sqlmail_test sslmode=disable" GO111MODULE=on SHUFFLE_CASES=1 PARALLEL_TESTS=1 23 | services: 24 | - postgresql 25 | before_install: 26 | - psql -c 'create database sqlmail_test;' -U postgres 27 | 28 | before_script: 29 | - go mod verify # ensure cache consistency 30 | 31 | script: 32 | - go test ./... -race -coverprofile=coverage.txt -covermode=atomic -tags $TEST_DB -count 8 -p $PARALLEL_TESTS -ldflags '-X github.com/foxcpp/go-imap-sql.defaultPassHashAlgo=sha3-512' 33 | 34 | after_success: 35 | - bash <(curl -s https://codecov.io/bash) 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright © 2019 Max Mazurov (fox.cpp) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | go-imap-sql 2 | [![Travis CI](https://img.shields.io/travis/com/foxcpp/go-imap-sql.svg?style=flat-square&logo=Linux)](https://travis-ci.com/foxcpp/go-imap-sql) 3 | [![CodeCov](https://img.shields.io/codecov/c/github/foxcpp/go-imap-sql.svg?style=flat-square)](https://codecov.io/gh/foxcpp/go-imap-sql) 4 | [![Reference](https://img.shields.io/badge/godoc-reference-blue.svg?style=flat-square)](https://godoc.org/github.com/foxcpp/go-imap-sql) 5 | [![stability-unstable](https://img.shields.io/badge/stability-unstable-yellow.svg?style=flat-square)](https://github.com/emersion/stability-badges#unstable) 6 | ============= 7 | 8 | SQL-based storage backend for [go-imap] library. 9 | 10 | Building 11 | ---------- 12 | 13 | Go 1.13 is required due to use of Go 1.13 error inspection features. 14 | 15 | RDBMS support 16 | --------------- 17 | 18 | go-imap-sql is known to work with (and constantly being tested against) following RDBMS: 19 | - SQLite 3.25.0 20 | - PostgreSQL 9.6 21 | 22 | Following RDBMS have experimental support: 23 | - CockroachDB 20.1.5 24 | 25 | Following RDBMS were actively supported in the past, it's unknown whether they 26 | still work with go-imap-sql: 27 | - MariaDB 10.2 28 | 29 | IMAP Extensions Supported 30 | --------------------------- 31 | 32 | - [CHILDREN] 33 | - [APPEND-LIMIT] 34 | - [MOVE] 35 | - [SPECIAL-USE] 36 | - [SORT] 37 | 38 | Authentication 39 | ---------------- 40 | 41 | go-imap-sql does not implement any authentication. "password" argument of Login 42 | method is not checked and the user account is created if it does not exist. You 43 | are supposed to wrap it to implement your own authentication the way you need 44 | it. 45 | 46 | Usernames case-insensitivity 47 | ------------------------------ 48 | 49 | Usernames are always converted to lower-case before doing anything. 50 | This means that if you type `imapsql-ctl ... users create FOXCPP`. Account 51 | with username `foxcpp` will be created. Also this means that you can use any 52 | case in account settings in your IMAP client. 53 | 54 | secure_delete 55 | ------------- 56 | 57 | You may want to overwrite deleted messages and theirs meta-data with zeroes for 58 | security/privacy reasons. 59 | For MySQL, PostgreSQL - consult documentation (AFAIK, there is no such option). 60 | 61 | For SQLite3, you should build go-imap-sql with `sqlite_secure_delete` build tag. 62 | It will enable corresponding SQLite3 feature by default for all databases. 63 | 64 | If you want to enable it per-database - you can use 65 | `file:PATH?_secure_delete=ON` in DSN. 66 | 67 | UIDVALIDITY 68 | ------------- 69 | 70 | go-imap-sql never invalidates UIDs in an existing mailbox. If mailbox is 71 | DELETE'd then UIDVALIDITY value changes. 72 | 73 | Unlike many popular IMAP server implementations, go-imap-sql uses randomly 74 | generated UIDVALIDITY values instead of timestamps. 75 | 76 | This makes several things easier to implement with less edge cases. And answer 77 | to the question you are already probably asked: To make go-imap-sql malfunction 78 | you need to get Go's PRNG to generate two equal integers in range of [1, 79 | 2^32-1] just at right moment (seems unlikely enough to ignore it). Even then, 80 | it will not cause much problems due to the way most client implementations 81 | work. 82 | 83 | go-imap-sql uses separate `math/rand.Rand` instance and seeds it with system 84 | time on initialization (in `New`). 85 | 86 | You can provide custom pre-seeded struct implementing `math/rand.Source` 87 | in `Opts` struct (`PRNG` field). 88 | 89 | Maddy 90 | ------- 91 | 92 | You can use go-imap-sql as part of the [maddy] mail server. 93 | 94 | imapsql-ctl 95 | ------------- 96 | 97 | For direct access to database you can use imapsql-ctl console utility. See more information in 98 | separate README [here](cmd/imapsql-ctl). 99 | ``` 100 | go install github.com/foxcpp/go-imap-sql/cmd/imapsql-ctl 101 | ``` 102 | 103 | [CHILDREN]: https://tools.ietf.org/html/rfc3348 104 | [APPEND-LIMIT]: https://tools.ietf.org/html/rfc7889 105 | [UIDPLUS]: https://tools.ietf.org/html/rfc4315 106 | [MOVE]: https://tools.ietf.org/html/rfc6851 107 | [SPECIAL-USE]: https://tools.ietf.org/html/rfc6154 108 | [SORT]: https://tools.ietf.org/html/rfc5256 109 | [go-imap]: https://github.com/emersion/go-imap 110 | [maddy]: https://github.com/emersion/maddy 111 | -------------------------------------------------------------------------------- /backend.go: -------------------------------------------------------------------------------- 1 | package imapsql 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | "fmt" 7 | mathrand "math/rand" 8 | "strings" 9 | "sync" 10 | "time" 11 | 12 | "github.com/emersion/go-imap" 13 | "github.com/emersion/go-imap/backend" 14 | mess "github.com/foxcpp/go-imap-mess" 15 | ) 16 | 17 | // VersionStr is a string value representing go-imap-sql version. 18 | // 19 | // Meant for debug logs, you may want to know which go-imap-sql version users 20 | // have. 21 | const VersionStr = "0.4.0" 22 | 23 | // SchemaVersion is incremented each time DB schema changes. 24 | const SchemaVersion = 6 25 | 26 | var ( 27 | ErrUserAlreadyExists = errors.New("imap: user already exists") 28 | ErrUserDoesntExists = errors.New("imap: user doesn't exists") 29 | ) 30 | 31 | type SerializationError struct { 32 | Err error 33 | } 34 | 35 | func (se SerializationError) Unwrap() error { 36 | return se.Err 37 | } 38 | 39 | func (se SerializationError) Error() string { 40 | return "imapsql: serialization failure, try again later" 41 | } 42 | 43 | type Rand interface { 44 | Uint32() uint32 45 | } 46 | 47 | type Logger interface { 48 | Printf(format string, v ...interface{}) 49 | Println(v ...interface{}) 50 | Debugf(format string, v ...interface{}) 51 | Debugln(v ...interface{}) 52 | } 53 | 54 | // Opts structure specifies additional settings that may be set 55 | // for backend. 56 | // 57 | // Please use names to reference structure members on creation, 58 | // fields may be reordered or added without major version increment. 59 | type Opts struct { 60 | // Adding unexported name to structures makes it impossible to 61 | // reference fields without naming them explicitly. 62 | _ struct{} 63 | 64 | // Maximum amount of bytes that backend will accept. 65 | // Intended for use with APPENDLIMIT extension. 66 | // nil value means no limit, 0 means zero limit (no new messages allowed) 67 | MaxMsgBytes *uint32 68 | 69 | // Custom randomness source for UIDVALIDITY values generation. 70 | PRNG Rand 71 | 72 | // (SQLite3 only) Don't force WAL journaling mode. 73 | NoWAL bool 74 | 75 | // (SQLite3 only) Use different value for busy_timeout. Default is 50000. 76 | // To set to 0, use -1 (you probably don't want this). 77 | BusyTimeout int 78 | 79 | // (SQLite3 only) Use EXCLUSIVE locking mode. 80 | ExclusiveLock bool 81 | 82 | // (SQLite3 only) Change page cache size. Positive value indicates cache 83 | // size in pages, negative in KiB. If set 0 - SQLite default will be used. 84 | CacheSize int 85 | 86 | // (SQLite3 only) Repack database file into minimal amount of disk space on 87 | // Close. 88 | // It runs VACUUM and PRAGMA wal_checkpoint(TRUNCATE). 89 | // Failures of these operations are ignored and don't affect return value 90 | // of Close. 91 | MinimizeOnClose bool 92 | 93 | // Compression algorithm to use for new messages. Empty string means no compression. 94 | // 95 | // Algorithms should be registered before using RegisterCompressionAlgo. 96 | CompressAlgo string 97 | 98 | // CompressAlgoParams is passed directly to compression algorithm without changes. 99 | CompressAlgoParams string 100 | 101 | // Disable RFC 3501-conforming handling of \Recent flag. This improves 102 | // performance significantly. 103 | DisableRecent bool 104 | 105 | Log Logger 106 | } 107 | 108 | type Backend struct { 109 | db db 110 | extStore ExternalStore 111 | mngr *mess.Manager 112 | 113 | // Opts structure used to construct this Backend object. 114 | // 115 | // For most cases it is safe to change options while backend is serving 116 | // requests. 117 | // Options that should NOT be changed while backend is processing commands: 118 | // - PRNG 119 | // - CompressAlgoParams 120 | // Changes for the following options have no effect after backend initialization: 121 | // - CompressAlgo 122 | // - ExclusiveLock 123 | // - CacheSize 124 | // - NoWAL 125 | Opts Opts 126 | 127 | // database/sql.DB object created by New. 128 | DB *sql.DB 129 | 130 | prng Rand 131 | compressAlgo CompressionAlgo 132 | 133 | // Shitton of pre-compiled SQL statements. 134 | userMeta *sql.Stmt 135 | listUsers *sql.Stmt 136 | addUser *sql.Stmt 137 | delUser *sql.Stmt 138 | listMboxes *sql.Stmt 139 | listSubbedMboxes *sql.Stmt 140 | createMboxExistsOk *sql.Stmt 141 | createMbox *sql.Stmt 142 | deleteMbox *sql.Stmt 143 | renameMbox *sql.Stmt 144 | renameMboxChilds *sql.Stmt 145 | getMboxAttrs *sql.Stmt 146 | setSubbed *sql.Stmt 147 | uidNextLocked *sql.Stmt 148 | uidNext *sql.Stmt 149 | hasChildren *sql.Stmt 150 | uidValidity *sql.Stmt 151 | msgsCount *sql.Stmt 152 | recentCount *sql.Stmt 153 | clearRecent *sql.Stmt 154 | firstUnseenUid *sql.Stmt 155 | unseenCount *sql.Stmt 156 | deletedUids *sql.Stmt 157 | expungeMbox *sql.Stmt 158 | mboxId *sql.Stmt 159 | addMsg *sql.Stmt 160 | copyMsgsUid *sql.Stmt 161 | copyMsgFlagsUid *sql.Stmt 162 | massClearFlagsUid *sql.Stmt 163 | msgFlagsUid *sql.Stmt 164 | usedFlags *sql.Stmt 165 | listMsgUids *sql.Stmt 166 | listMsgUidsRecent *sql.Stmt 167 | 168 | addRecentToLast *sql.Stmt 169 | 170 | // 'mark' column for messages is used to keep track of messages selected 171 | // by sequence numbers during operations that may cause seqence numbers to 172 | // change (e.g. message deletion) 173 | // 174 | // Consider following request: Delete messages with seqnum 1 and 3. 175 | // Naive implementation will delete 1st and then 3rd messages in mailbox. 176 | // However, after first operation 3rd message will become 2nd and 177 | // code will end up deleting the wrong message (4th actually). 178 | // 179 | // Solution is to "mark" 1st and 3rd message and then delete all "marked" 180 | // message. 181 | // 182 | // One could use \Deleted flag for this purpose, but this 183 | // requires more expensive operations at SQL engine side, so 'mark' column 184 | // is basically a optimization. 185 | 186 | // For MOVE extension 187 | markUid *sql.Stmt 188 | delMarked *sql.Stmt 189 | markedUids *sql.Stmt 190 | 191 | lastUid *sql.Stmt 192 | 193 | // For APPEND-LIMIT extension 194 | setUserMsgSizeLimit *sql.Stmt 195 | userMsgSizeLimit *sql.Stmt 196 | setMboxMsgSizeLimit *sql.Stmt 197 | mboxMsgSizeLimit *sql.Stmt 198 | 199 | searchFetchNoSeq *sql.Stmt 200 | 201 | flagsSearchStmtsLck sync.RWMutex 202 | flagsSearchStmtsCache map[string]*sql.Stmt 203 | fetchStmtsLck sync.RWMutex 204 | fetchStmtsCache map[string]*sql.Stmt 205 | addFlagsStmtsLck sync.RWMutex 206 | addFlagsStmtsCache map[string]*sql.Stmt 207 | remFlagsStmtsLck sync.RWMutex 208 | remFlagsStmtsCache map[string]*sql.Stmt 209 | 210 | // extkeys table 211 | addExtKey *sql.Stmt 212 | decreaseRefForMarked *sql.Stmt 213 | decreaseRefForDeleted *sql.Stmt 214 | incrementRefUid *sql.Stmt 215 | zeroRef *sql.Stmt 216 | zeroRefUser *sql.Stmt 217 | refUser *sql.Stmt 218 | deleteZeroRef *sql.Stmt 219 | deleteUserRef *sql.Stmt 220 | decreaseRefForMbox *sql.Stmt 221 | 222 | // Used by Delivery.SpecialMailbox. 223 | specialUseMbox *sql.Stmt 224 | 225 | setSeenFlagUid *sql.Stmt 226 | increaseMsgCount *sql.Stmt 227 | decreaseMsgCount *sql.Stmt 228 | 229 | setInboxId *sql.Stmt 230 | 231 | cachedHeaderUid *sql.Stmt 232 | 233 | sqliteOptimizeLoopStop chan struct{} 234 | } 235 | 236 | var defaultPassHashAlgo = "bcrypt" 237 | 238 | // New creates new Backend instance using provided configuration. 239 | // 240 | // driver and dsn arguments are passed directly to sql.Open. 241 | // 242 | // Note that it is not safe to create multiple Backend instances working with 243 | // the single database as they need to keep some state synchronized and there 244 | // is no measures for this implemented in go-imap-sql. 245 | func New(driver, dsn string, extStore ExternalStore, opts Opts) (*Backend, error) { 246 | b := &Backend{ 247 | fetchStmtsCache: make(map[string]*sql.Stmt), 248 | flagsSearchStmtsCache: make(map[string]*sql.Stmt), 249 | addFlagsStmtsCache: make(map[string]*sql.Stmt), 250 | remFlagsStmtsCache: make(map[string]*sql.Stmt), 251 | 252 | sqliteOptimizeLoopStop: make(chan struct{}), 253 | 254 | extStore: extStore, 255 | Opts: opts, 256 | 257 | mngr: mess.NewManager(), 258 | } 259 | var err error 260 | 261 | if b.Opts.CompressAlgo != "" { 262 | impl, ok := compressionAlgos[b.Opts.CompressAlgo] 263 | if !ok { 264 | return nil, fmt.Errorf("New: unknown compression algorithm: %s", b.Opts.CompressAlgo) 265 | } 266 | 267 | b.compressAlgo = impl 268 | } else { 269 | b.compressAlgo = nullCompression{} 270 | } 271 | 272 | b.Opts = opts 273 | 274 | if b.Opts.Log == nil { 275 | b.Opts.Log = globalLogger{} 276 | } 277 | 278 | if b.Opts.PRNG != nil { 279 | b.prng = opts.PRNG 280 | } else { 281 | b.prng = mathrand.New(mathrand.NewSource(time.Now().Unix())) 282 | } 283 | 284 | if driver == "sqlite3" { 285 | dsn = b.addSqlite3Params(dsn) 286 | } 287 | 288 | b.db.driver = driver 289 | b.db.dsn = dsn 290 | 291 | b.db.DB, err = sql.Open(driver, dsn) 292 | if err != nil { 293 | return nil, wrapErr(err, "NewBackend (open)") 294 | } 295 | b.DB = b.db.DB 296 | 297 | ver, err := b.schemaVersion() 298 | if err != nil { 299 | return nil, wrapErr(err, "NewBackend (schemaVersion)") 300 | } 301 | // Zero version indicates "empty database". 302 | if ver > SchemaVersion { 303 | return nil, fmt.Errorf("incompatible database schema, too new (%d > %d)", ver, SchemaVersion) 304 | } 305 | if ver < SchemaVersion && ver != 0 { 306 | b.Opts.Log.Printf("Upgrading database schema (from %d to %d)", ver, SchemaVersion) 307 | if err := b.upgradeSchema(ver); err != nil { 308 | return nil, wrapErr(err, "NewBackend (schemaUpgrade)") 309 | } 310 | } 311 | if err := b.setSchemaVersion(SchemaVersion); err != nil { 312 | return nil, wrapErr(err, "NewBackend (setSchemaVersion)") 313 | } 314 | 315 | if err := b.configureEngine(); err != nil { 316 | return nil, wrapErr(err, "NewBackend (configureEngine)") 317 | } 318 | 319 | if err := b.initSchema(); err != nil { 320 | return nil, wrapErr(err, "NewBackend (initSchema)") 321 | } 322 | if err := b.prepareStmts(); err != nil { 323 | return nil, wrapErr(err, "NewBackend (prepareStmts)") 324 | } 325 | 326 | for _, item := range [...]imap.FetchItem{ 327 | imap.FetchFlags, imap.FetchEnvelope, 328 | imap.FetchBodyStructure, "BODY[]", "BODY[HEADER.FIELDS (From To)]"} { 329 | 330 | if _, err := b.getFetchStmt([]imap.FetchItem{item}); err != nil { 331 | return nil, wrapErrf(err, "fetchStmt prime (%s)", item) 332 | } 333 | } 334 | 335 | if b.db.driver == "sqlite3" { 336 | go b.sqliteOptimizeLoop() 337 | } 338 | 339 | return b, nil 340 | } 341 | 342 | func (b *Backend) UpdateManager() *mess.Manager { 343 | return b.mngr 344 | } 345 | 346 | func (b *Backend) sqliteOptimizeLoop() { 347 | t := time.NewTicker(5 * time.Hour) 348 | defer t.Stop() 349 | for { 350 | select { 351 | case <-t.C: 352 | b.Opts.Log.Debugln("running SQLite query planer optimization...") 353 | b.db.Exec(`PRAGMA optimize`) 354 | b.Opts.Log.Debugln("completed SQLite query planer optimization") 355 | case <-b.sqliteOptimizeLoopStop: 356 | return 357 | } 358 | } 359 | } 360 | 361 | func (b *Backend) Close() error { 362 | if b.db.driver == "sqlite3" { 363 | // These operations are not critical, so it's not a problem if they fail. 364 | if b.Opts.MinimizeOnClose { 365 | b.db.Exec(`VACUUM`) 366 | b.db.Exec(`PRAGMA wal_checkpoint(TRUNCATE)`) 367 | } 368 | 369 | b.sqliteOptimizeLoopStop <- struct{}{} 370 | b.db.Exec(`PRAGMA optimize`) 371 | } 372 | 373 | return b.db.Close() 374 | } 375 | 376 | func (b *Backend) getUserMeta(tx *sql.Tx, username string) (id uint64, inboxId uint64, err error) { 377 | var row *sql.Row 378 | if tx != nil { 379 | row = tx.Stmt(b.userMeta).QueryRow(username) 380 | } else { 381 | row = b.userMeta.QueryRow(username) 382 | } 383 | if err := row.Scan(&id, &inboxId); err != nil { 384 | return 0, 0, err 385 | } 386 | return id, inboxId, nil 387 | } 388 | 389 | func normalizeUsername(u string) string { 390 | return strings.ToLower(u) 391 | } 392 | 393 | // CreateUser creates user account. 394 | func (b *Backend) CreateUser(username string) error { 395 | _, _, err := b.createUser(nil, normalizeUsername(username)) 396 | return err 397 | } 398 | 399 | func (b *Backend) createUser(tx *sql.Tx, username string) (uid, inboxId uint64, err error) { 400 | var shouldCommit bool 401 | if tx == nil { 402 | var err error 403 | tx, err = b.db.Begin(false) 404 | if err != nil { 405 | return 0, 0, wrapErr(err, "CreateUser") 406 | } 407 | defer tx.Rollback() 408 | shouldCommit = true 409 | } 410 | 411 | _, err = tx.Stmt(b.addUser).Exec(username) 412 | if err != nil && isForeignKeyErr(err) { 413 | return 0, 0, ErrUserAlreadyExists 414 | } 415 | 416 | // TODO: Cut additional query here by using RETURNING on PostgreSQL. 417 | uid, _, err = b.getUserMeta(tx, username) 418 | if err != nil { 419 | return 0, 0, wrapErr(err, "CreateUser") 420 | } 421 | 422 | // Every new user needs to have at least one mailbox (INBOX). 423 | if _, err := tx.Stmt(b.createMbox).Exec(uid, "INBOX", b.prng.Uint32(), nil); err != nil { 424 | return 0, 0, wrapErr(err, "CreateUser") 425 | } 426 | 427 | // TODO: Cut another query here by using RETURNING on PostgreSQL. 428 | if err = tx.Stmt(b.mboxId).QueryRow(uid, "INBOX").Scan(&inboxId); err != nil { 429 | return 0, 0, wrapErr(err, "CreateUser") 430 | } 431 | if _, err = tx.Stmt(b.setInboxId).Exec(inboxId, uid); err != nil { 432 | return 0, 0, wrapErr(err, "CreateUser") 433 | } 434 | 435 | if shouldCommit { 436 | return uid, inboxId, tx.Commit() 437 | } 438 | return uid, inboxId, nil 439 | } 440 | 441 | // DeleteUser deleted user account with specified username. 442 | // 443 | // It is error to delete account that doesn't exist, ErrUserDoesntExists will 444 | // be returned in this case. 445 | func (b *Backend) DeleteUser(username string) error { 446 | username = strings.ToLower(username) 447 | 448 | tx, err := b.db.BeginLevel(sql.LevelReadCommitted, false) 449 | if err != nil { 450 | return wrapErr(err, "DeleteUser") 451 | } 452 | defer tx.Rollback() 453 | 454 | // TODO: These queries definitely can be merged on PostgreSQL. 455 | var keys []string 456 | rows, err := tx.Stmt(b.refUser).Query(username) 457 | if err != nil { 458 | return wrapErr(err, "DeleteUser") 459 | } 460 | defer rows.Close() 461 | for rows.Next() { 462 | var key string 463 | if err := rows.Scan(&key); err != nil { 464 | return wrapErr(err, "DeleteUser") 465 | } 466 | keys = append(keys, key) 467 | } 468 | 469 | stats, err := tx.Stmt(b.delUser).Exec(username) 470 | if err != nil { 471 | return wrapErr(err, "DeleteUser") 472 | } 473 | affected, err := stats.RowsAffected() 474 | if err != nil { 475 | return wrapErr(err, "DeleteUser") 476 | } 477 | if affected == 0 { 478 | return ErrUserDoesntExists 479 | } 480 | 481 | if err := b.extStore.Delete(keys); err != nil { 482 | return wrapErr(err, "DeleteUser") 483 | } 484 | 485 | if _, err := tx.Stmt(b.deleteUserRef).Exec(username); err != nil { 486 | return wrapErr(err, "DeleteUser") 487 | } 488 | 489 | return tx.Commit() 490 | } 491 | 492 | // ListUsers returns list of existing usernames. 493 | // 494 | // It may return nil slice if no users are registered. 495 | func (b *Backend) ListUsers() ([]string, error) { 496 | var res []string 497 | rows, err := b.listUsers.Query() 498 | if err != nil { 499 | return res, wrapErr(err, "ListUsers") 500 | } 501 | for rows.Next() { 502 | var id uint64 503 | var name string 504 | if err := rows.Scan(&id, &name); err != nil { 505 | return res, wrapErr(err, "ListUsers") 506 | } 507 | res = append(res, name) 508 | } 509 | if err := rows.Err(); err != nil { 510 | return res, wrapErr(err, "ListUsers") 511 | } 512 | return res, nil 513 | } 514 | 515 | // GetUser creates backend.User object for the user credentials. 516 | func (b *Backend) GetUser(username string) (backend.User, error) { 517 | username = normalizeUsername(username) 518 | 519 | uid, inboxId, err := b.getUserMeta(nil, username) 520 | if err != nil { 521 | if err == sql.ErrNoRows { 522 | return nil, ErrUserDoesntExists 523 | } 524 | return nil, err 525 | } 526 | return &User{id: uid, username: username, parent: b, inboxId: inboxId}, nil 527 | } 528 | 529 | // GetOrCreateUser is a convenience wrapper for GetUser and CreateUser. 530 | // 531 | // All database operations are executed within one transaction so 532 | // this method is atomic as defined by used RDBMS. 533 | func (b *Backend) GetOrCreateUser(username string) (backend.User, error) { 534 | username = normalizeUsername(username) 535 | 536 | tx, err := b.db.Begin(false) 537 | if err != nil { 538 | return nil, err 539 | } 540 | defer tx.Rollback() 541 | 542 | uid, inboxId, err := b.getUserMeta(tx, username) 543 | if err != nil { 544 | if err == sql.ErrNoRows { 545 | b.Opts.Log.Println("auto-creating storage account", username) 546 | if uid, inboxId, err = b.createUser(tx, username); err != nil { 547 | return nil, err 548 | } 549 | } else { 550 | return nil, err 551 | } 552 | } 553 | return &User{id: uid, username: username, parent: b, inboxId: inboxId}, tx.Commit() 554 | } 555 | 556 | func (b *Backend) Login(_ *imap.ConnInfo, username, password string) (backend.User, error) { 557 | u, err := b.GetOrCreateUser(username) 558 | if err != nil { 559 | return nil, err 560 | } 561 | b.Opts.Log.Debugln(username, "logged in") 562 | return u, nil 563 | } 564 | 565 | func (b *Backend) CreateMessageLimit() *uint32 { 566 | return b.Opts.MaxMsgBytes 567 | } 568 | 569 | // Change global APPEND limit, Opts.MaxMsgBytes. 570 | // 571 | // Provided to implement interfaces used by go-imap-backend-tests. 572 | func (b *Backend) SetMessageLimit(val *uint32) error { 573 | b.Opts.MaxMsgBytes = val 574 | return nil 575 | } 576 | -------------------------------------------------------------------------------- /bodystructure_easyjson.go: -------------------------------------------------------------------------------- 1 | // Code generated by easyjson for marshaling/unmarshaling. Patched 2 | // by hand to work with types located in a different package. 3 | 4 | package imapsql 5 | 6 | import ( 7 | "github.com/emersion/go-imap" 8 | "github.com/mailru/easyjson/jlexer" 9 | "github.com/mailru/easyjson/jwriter" 10 | ) 11 | 12 | func easyjsonUnmarshalEnvelope(in *jlexer.Lexer, out *imap.Envelope) { 13 | isTopLevel := in.IsStart() 14 | if in.IsNull() { 15 | if isTopLevel { 16 | in.Consumed() 17 | } 18 | in.Skip() 19 | return 20 | } 21 | in.Delim('{') 22 | for !in.IsDelim('}') { 23 | key := in.UnsafeString() 24 | in.WantColon() 25 | if in.IsNull() { 26 | in.Skip() 27 | in.WantComma() 28 | continue 29 | } 30 | switch key { 31 | case "Date": 32 | if data := in.Raw(); in.Ok() { 33 | in.AddError((out.Date).UnmarshalJSON(data)) 34 | } 35 | case "Subject": 36 | out.Subject = in.String() 37 | case "From": 38 | if in.IsNull() { 39 | in.Skip() 40 | out.From = nil 41 | } else { 42 | in.Delim('[') 43 | if out.From == nil { 44 | if !in.IsDelim(']') { 45 | out.From = make([]*imap.Address, 0, 8) 46 | } else { 47 | out.From = []*imap.Address{} 48 | } 49 | } else { 50 | out.From = (out.From)[:0] 51 | } 52 | for !in.IsDelim(']') { 53 | var v1 *imap.Address 54 | if in.IsNull() { 55 | in.Skip() 56 | v1 = nil 57 | } else { 58 | if v1 == nil { 59 | v1 = new(imap.Address) 60 | } 61 | easyjsonUnmarshalAddress(in, v1) 62 | } 63 | out.From = append(out.From, v1) 64 | in.WantComma() 65 | } 66 | in.Delim(']') 67 | } 68 | case "Sender": 69 | if in.IsNull() { 70 | in.Skip() 71 | out.Sender = nil 72 | } else { 73 | in.Delim('[') 74 | if out.Sender == nil { 75 | if !in.IsDelim(']') { 76 | out.Sender = make([]*imap.Address, 0, 8) 77 | } else { 78 | out.Sender = []*imap.Address{} 79 | } 80 | } else { 81 | out.Sender = (out.Sender)[:0] 82 | } 83 | for !in.IsDelim(']') { 84 | var v2 *imap.Address 85 | if in.IsNull() { 86 | in.Skip() 87 | v2 = nil 88 | } else { 89 | if v2 == nil { 90 | v2 = new(imap.Address) 91 | } 92 | easyjsonUnmarshalAddress(in, v2) 93 | } 94 | out.Sender = append(out.Sender, v2) 95 | in.WantComma() 96 | } 97 | in.Delim(']') 98 | } 99 | case "ReplyTo": 100 | if in.IsNull() { 101 | in.Skip() 102 | out.ReplyTo = nil 103 | } else { 104 | in.Delim('[') 105 | if out.ReplyTo == nil { 106 | if !in.IsDelim(']') { 107 | out.ReplyTo = make([]*imap.Address, 0, 8) 108 | } else { 109 | out.ReplyTo = []*imap.Address{} 110 | } 111 | } else { 112 | out.ReplyTo = (out.ReplyTo)[:0] 113 | } 114 | for !in.IsDelim(']') { 115 | var v3 *imap.Address 116 | if in.IsNull() { 117 | in.Skip() 118 | v3 = nil 119 | } else { 120 | if v3 == nil { 121 | v3 = new(imap.Address) 122 | } 123 | easyjsonUnmarshalAddress(in, v3) 124 | } 125 | out.ReplyTo = append(out.ReplyTo, v3) 126 | in.WantComma() 127 | } 128 | in.Delim(']') 129 | } 130 | case "To": 131 | if in.IsNull() { 132 | in.Skip() 133 | out.To = nil 134 | } else { 135 | in.Delim('[') 136 | if out.To == nil { 137 | if !in.IsDelim(']') { 138 | out.To = make([]*imap.Address, 0, 8) 139 | } else { 140 | out.To = []*imap.Address{} 141 | } 142 | } else { 143 | out.To = (out.To)[:0] 144 | } 145 | for !in.IsDelim(']') { 146 | var v4 *imap.Address 147 | if in.IsNull() { 148 | in.Skip() 149 | v4 = nil 150 | } else { 151 | if v4 == nil { 152 | v4 = new(imap.Address) 153 | } 154 | easyjsonUnmarshalAddress(in, v4) 155 | } 156 | out.To = append(out.To, v4) 157 | in.WantComma() 158 | } 159 | in.Delim(']') 160 | } 161 | case "Cc": 162 | if in.IsNull() { 163 | in.Skip() 164 | out.Cc = nil 165 | } else { 166 | in.Delim('[') 167 | if out.Cc == nil { 168 | if !in.IsDelim(']') { 169 | out.Cc = make([]*imap.Address, 0, 8) 170 | } else { 171 | out.Cc = []*imap.Address{} 172 | } 173 | } else { 174 | out.Cc = (out.Cc)[:0] 175 | } 176 | for !in.IsDelim(']') { 177 | var v5 *imap.Address 178 | if in.IsNull() { 179 | in.Skip() 180 | v5 = nil 181 | } else { 182 | if v5 == nil { 183 | v5 = new(imap.Address) 184 | } 185 | easyjsonUnmarshalAddress(in, v5) 186 | } 187 | out.Cc = append(out.Cc, v5) 188 | in.WantComma() 189 | } 190 | in.Delim(']') 191 | } 192 | case "Bcc": 193 | if in.IsNull() { 194 | in.Skip() 195 | out.Bcc = nil 196 | } else { 197 | in.Delim('[') 198 | if out.Bcc == nil { 199 | if !in.IsDelim(']') { 200 | out.Bcc = make([]*imap.Address, 0, 8) 201 | } else { 202 | out.Bcc = []*imap.Address{} 203 | } 204 | } else { 205 | out.Bcc = (out.Bcc)[:0] 206 | } 207 | for !in.IsDelim(']') { 208 | var v6 *imap.Address 209 | if in.IsNull() { 210 | in.Skip() 211 | v6 = nil 212 | } else { 213 | if v6 == nil { 214 | v6 = new(imap.Address) 215 | } 216 | easyjsonUnmarshalAddress(in, v6) 217 | } 218 | out.Bcc = append(out.Bcc, v6) 219 | in.WantComma() 220 | } 221 | in.Delim(']') 222 | } 223 | case "InReplyTo": 224 | out.InReplyTo = string(in.String()) 225 | case "MessageId": 226 | out.MessageId = string(in.String()) 227 | default: 228 | in.SkipRecursive() 229 | } 230 | in.WantComma() 231 | } 232 | in.Delim('}') 233 | if isTopLevel { 234 | in.Consumed() 235 | } 236 | } 237 | 238 | func easyjsonMarshalEnvelope(out *jwriter.Writer, in imap.Envelope) { 239 | out.RawByte('{') 240 | first := true 241 | _ = first 242 | { 243 | const prefix string = ",\"Date\":" 244 | out.RawString(prefix[1:]) 245 | out.Raw((in.Date).MarshalJSON()) 246 | } 247 | { 248 | const prefix string = ",\"Subject\":" 249 | out.RawString(prefix) 250 | out.String(in.Subject) 251 | } 252 | { 253 | const prefix string = ",\"From\":" 254 | out.RawString(prefix) 255 | if in.From == nil && (out.Flags&jwriter.NilSliceAsEmpty) == 0 { 256 | out.RawString("null") 257 | } else { 258 | out.RawByte('[') 259 | for v7, v8 := range in.From { 260 | if v7 > 0 { 261 | out.RawByte(',') 262 | } 263 | if v8 == nil { 264 | out.RawString("null") 265 | } else { 266 | easyjsonMarshalAddress(out, *v8) 267 | } 268 | } 269 | out.RawByte(']') 270 | } 271 | } 272 | { 273 | const prefix string = ",\"Sender\":" 274 | out.RawString(prefix) 275 | if in.Sender == nil && (out.Flags&jwriter.NilSliceAsEmpty) == 0 { 276 | out.RawString("null") 277 | } else { 278 | out.RawByte('[') 279 | for v9, v10 := range in.Sender { 280 | if v9 > 0 { 281 | out.RawByte(',') 282 | } 283 | if v10 == nil { 284 | out.RawString("null") 285 | } else { 286 | easyjsonMarshalAddress(out, *v10) 287 | } 288 | } 289 | out.RawByte(']') 290 | } 291 | } 292 | { 293 | const prefix string = ",\"ReplyTo\":" 294 | out.RawString(prefix) 295 | if in.ReplyTo == nil && (out.Flags&jwriter.NilSliceAsEmpty) == 0 { 296 | out.RawString("null") 297 | } else { 298 | out.RawByte('[') 299 | for v11, v12 := range in.ReplyTo { 300 | if v11 > 0 { 301 | out.RawByte(',') 302 | } 303 | if v12 == nil { 304 | out.RawString("null") 305 | } else { 306 | easyjsonMarshalAddress(out, *v12) 307 | } 308 | } 309 | out.RawByte(']') 310 | } 311 | } 312 | { 313 | const prefix string = ",\"To\":" 314 | out.RawString(prefix) 315 | if in.To == nil && (out.Flags&jwriter.NilSliceAsEmpty) == 0 { 316 | out.RawString("null") 317 | } else { 318 | out.RawByte('[') 319 | for v13, v14 := range in.To { 320 | if v13 > 0 { 321 | out.RawByte(',') 322 | } 323 | if v14 == nil { 324 | out.RawString("null") 325 | } else { 326 | easyjsonMarshalAddress(out, *v14) 327 | } 328 | } 329 | out.RawByte(']') 330 | } 331 | } 332 | { 333 | const prefix string = ",\"Cc\":" 334 | out.RawString(prefix) 335 | if in.Cc == nil && (out.Flags&jwriter.NilSliceAsEmpty) == 0 { 336 | out.RawString("null") 337 | } else { 338 | out.RawByte('[') 339 | for v15, v16 := range in.Cc { 340 | if v15 > 0 { 341 | out.RawByte(',') 342 | } 343 | if v16 == nil { 344 | out.RawString("null") 345 | } else { 346 | easyjsonMarshalAddress(out, *v16) 347 | } 348 | } 349 | out.RawByte(']') 350 | } 351 | } 352 | { 353 | const prefix string = ",\"Bcc\":" 354 | out.RawString(prefix) 355 | if in.Bcc == nil && (out.Flags&jwriter.NilSliceAsEmpty) == 0 { 356 | out.RawString("null") 357 | } else { 358 | out.RawByte('[') 359 | for v17, v18 := range in.Bcc { 360 | if v17 > 0 { 361 | out.RawByte(',') 362 | } 363 | if v18 == nil { 364 | out.RawString("null") 365 | } else { 366 | easyjsonMarshalAddress(out, *v18) 367 | } 368 | } 369 | out.RawByte(']') 370 | } 371 | } 372 | { 373 | const prefix string = ",\"InReplyTo\":" 374 | out.RawString(prefix) 375 | out.String(string(in.InReplyTo)) 376 | } 377 | { 378 | const prefix string = ",\"MessageId\":" 379 | out.RawString(prefix) 380 | out.String(string(in.MessageId)) 381 | } 382 | out.RawByte('}') 383 | } 384 | 385 | func easyjsonUnmarshalBodyStruct(in *jlexer.Lexer, out *imap.BodyStructure) { 386 | isTopLevel := in.IsStart() 387 | if in.IsNull() { 388 | if isTopLevel { 389 | in.Consumed() 390 | } 391 | in.Skip() 392 | return 393 | } 394 | in.Delim('{') 395 | for !in.IsDelim('}') { 396 | key := in.UnsafeString() 397 | in.WantColon() 398 | if in.IsNull() { 399 | in.Skip() 400 | in.WantComma() 401 | continue 402 | } 403 | switch key { 404 | case "MIMEType": 405 | out.MIMEType = string(in.String()) 406 | case "MIMESubType": 407 | out.MIMESubType = string(in.String()) 408 | case "Params": 409 | if in.IsNull() { 410 | in.Skip() 411 | } else { 412 | in.Delim('{') 413 | if !in.IsDelim('}') { 414 | out.Params = make(map[string]string) 415 | } else { 416 | out.Params = nil 417 | } 418 | for !in.IsDelim('}') { 419 | key := string(in.String()) 420 | in.WantColon() 421 | var v19 string 422 | v19 = string(in.String()) 423 | (out.Params)[key] = v19 424 | in.WantComma() 425 | } 426 | in.Delim('}') 427 | } 428 | case "Id": 429 | out.Id = string(in.String()) 430 | case "Description": 431 | out.Description = string(in.String()) 432 | case "Encoding": 433 | out.Encoding = string(in.String()) 434 | case "Size": 435 | out.Size = uint32(in.Uint32()) 436 | case "Parts": 437 | if in.IsNull() { 438 | in.Skip() 439 | out.Parts = nil 440 | } else { 441 | in.Delim('[') 442 | if out.Parts == nil { 443 | if !in.IsDelim(']') { 444 | out.Parts = make([]*imap.BodyStructure, 0, 8) 445 | } else { 446 | out.Parts = []*imap.BodyStructure{} 447 | } 448 | } else { 449 | out.Parts = (out.Parts)[:0] 450 | } 451 | for !in.IsDelim(']') { 452 | var v20 *imap.BodyStructure 453 | if in.IsNull() { 454 | in.Skip() 455 | v20 = nil 456 | } else { 457 | if v20 == nil { 458 | v20 = new(imap.BodyStructure) 459 | } 460 | easyjsonUnmarshalBodyStruct(in, v20) 461 | } 462 | out.Parts = append(out.Parts, v20) 463 | in.WantComma() 464 | } 465 | in.Delim(']') 466 | } 467 | case "Envelope": 468 | if in.IsNull() { 469 | in.Skip() 470 | out.Envelope = nil 471 | } else { 472 | if out.Envelope == nil { 473 | out.Envelope = new(imap.Envelope) 474 | } 475 | easyjsonUnmarshalEnvelope(in, out.Envelope) 476 | } 477 | case "BodyStructure": 478 | if in.IsNull() { 479 | in.Skip() 480 | out.BodyStructure = nil 481 | } else { 482 | if out.BodyStructure == nil { 483 | out.BodyStructure = new(imap.BodyStructure) 484 | } 485 | easyjsonUnmarshalBodyStruct(in, out.BodyStructure) 486 | } 487 | case "Lines": 488 | out.Lines = uint32(in.Uint32()) 489 | case "Extended": 490 | out.Extended = bool(in.Bool()) 491 | case "Disposition": 492 | out.Disposition = string(in.String()) 493 | case "DispositionParams": 494 | if in.IsNull() { 495 | in.Skip() 496 | } else { 497 | in.Delim('{') 498 | if !in.IsDelim('}') { 499 | out.DispositionParams = make(map[string]string) 500 | } else { 501 | out.DispositionParams = nil 502 | } 503 | for !in.IsDelim('}') { 504 | key := string(in.String()) 505 | in.WantColon() 506 | var v21 string 507 | v21 = string(in.String()) 508 | (out.DispositionParams)[key] = v21 509 | in.WantComma() 510 | } 511 | in.Delim('}') 512 | } 513 | case "Language": 514 | if in.IsNull() { 515 | in.Skip() 516 | out.Language = nil 517 | } else { 518 | in.Delim('[') 519 | if out.Language == nil { 520 | if !in.IsDelim(']') { 521 | out.Language = make([]string, 0, 4) 522 | } else { 523 | out.Language = []string{} 524 | } 525 | } else { 526 | out.Language = (out.Language)[:0] 527 | } 528 | for !in.IsDelim(']') { 529 | var v22 string 530 | v22 = string(in.String()) 531 | out.Language = append(out.Language, v22) 532 | in.WantComma() 533 | } 534 | in.Delim(']') 535 | } 536 | case "Location": 537 | if in.IsNull() { 538 | in.Skip() 539 | out.Location = nil 540 | } else { 541 | in.Delim('[') 542 | if out.Location == nil { 543 | if !in.IsDelim(']') { 544 | out.Location = make([]string, 0, 4) 545 | } else { 546 | out.Location = []string{} 547 | } 548 | } else { 549 | out.Location = (out.Location)[:0] 550 | } 551 | for !in.IsDelim(']') { 552 | var v23 string 553 | v23 = string(in.String()) 554 | out.Location = append(out.Location, v23) 555 | in.WantComma() 556 | } 557 | in.Delim(']') 558 | } 559 | case "MD5": 560 | out.MD5 = string(in.String()) 561 | default: 562 | in.SkipRecursive() 563 | } 564 | in.WantComma() 565 | } 566 | in.Delim('}') 567 | if isTopLevel { 568 | in.Consumed() 569 | } 570 | } 571 | func easyjsonMarshalBodyStruct(out *jwriter.Writer, in imap.BodyStructure) { 572 | out.RawByte('{') 573 | first := true 574 | _ = first 575 | { 576 | const prefix string = ",\"MIMEType\":" 577 | out.RawString(prefix[1:]) 578 | out.String(string(in.MIMEType)) 579 | } 580 | { 581 | const prefix string = ",\"MIMESubType\":" 582 | out.RawString(prefix) 583 | out.String(string(in.MIMESubType)) 584 | } 585 | { 586 | const prefix string = ",\"Params\":" 587 | out.RawString(prefix) 588 | if in.Params == nil && (out.Flags&jwriter.NilMapAsEmpty) == 0 { 589 | out.RawString(`null`) 590 | } else { 591 | out.RawByte('{') 592 | v24First := true 593 | for v24Name, v24Value := range in.Params { 594 | if v24First { 595 | v24First = false 596 | } else { 597 | out.RawByte(',') 598 | } 599 | out.String(string(v24Name)) 600 | out.RawByte(':') 601 | out.String(string(v24Value)) 602 | } 603 | out.RawByte('}') 604 | } 605 | } 606 | { 607 | const prefix string = ",\"Id\":" 608 | out.RawString(prefix) 609 | out.String(string(in.Id)) 610 | } 611 | { 612 | const prefix string = ",\"Description\":" 613 | out.RawString(prefix) 614 | out.String(string(in.Description)) 615 | } 616 | { 617 | const prefix string = ",\"Encoding\":" 618 | out.RawString(prefix) 619 | out.String(string(in.Encoding)) 620 | } 621 | { 622 | const prefix string = ",\"Size\":" 623 | out.RawString(prefix) 624 | out.Uint32(uint32(in.Size)) 625 | } 626 | { 627 | const prefix string = ",\"Parts\":" 628 | out.RawString(prefix) 629 | if in.Parts == nil && (out.Flags&jwriter.NilSliceAsEmpty) == 0 { 630 | out.RawString("null") 631 | } else { 632 | out.RawByte('[') 633 | for v25, v26 := range in.Parts { 634 | if v25 > 0 { 635 | out.RawByte(',') 636 | } 637 | if v26 == nil { 638 | out.RawString("null") 639 | } else { 640 | easyjsonMarshalBodyStruct(out, *v26) 641 | } 642 | } 643 | out.RawByte(']') 644 | } 645 | } 646 | { 647 | const prefix string = ",\"Envelope\":" 648 | out.RawString(prefix) 649 | if in.Envelope == nil { 650 | out.RawString("null") 651 | } else { 652 | easyjsonMarshalEnvelope(out, *in.Envelope) 653 | } 654 | } 655 | { 656 | const prefix string = ",\"BodyStructure\":" 657 | out.RawString(prefix) 658 | if in.BodyStructure == nil { 659 | out.RawString("null") 660 | } else { 661 | easyjsonMarshalBodyStruct(out, *in.BodyStructure) 662 | } 663 | } 664 | { 665 | const prefix string = ",\"Lines\":" 666 | out.RawString(prefix) 667 | out.Uint32(uint32(in.Lines)) 668 | } 669 | { 670 | const prefix string = ",\"Extended\":" 671 | out.RawString(prefix) 672 | out.Bool(bool(in.Extended)) 673 | } 674 | { 675 | const prefix string = ",\"Disposition\":" 676 | out.RawString(prefix) 677 | out.String(string(in.Disposition)) 678 | } 679 | { 680 | const prefix string = ",\"DispositionParams\":" 681 | out.RawString(prefix) 682 | if in.DispositionParams == nil && (out.Flags&jwriter.NilMapAsEmpty) == 0 { 683 | out.RawString(`null`) 684 | } else { 685 | out.RawByte('{') 686 | v27First := true 687 | for v27Name, v27Value := range in.DispositionParams { 688 | if v27First { 689 | v27First = false 690 | } else { 691 | out.RawByte(',') 692 | } 693 | out.String(string(v27Name)) 694 | out.RawByte(':') 695 | out.String(string(v27Value)) 696 | } 697 | out.RawByte('}') 698 | } 699 | } 700 | { 701 | const prefix string = ",\"Language\":" 702 | out.RawString(prefix) 703 | if in.Language == nil && (out.Flags&jwriter.NilSliceAsEmpty) == 0 { 704 | out.RawString("null") 705 | } else { 706 | out.RawByte('[') 707 | for v28, v29 := range in.Language { 708 | if v28 > 0 { 709 | out.RawByte(',') 710 | } 711 | out.String(string(v29)) 712 | } 713 | out.RawByte(']') 714 | } 715 | } 716 | { 717 | const prefix string = ",\"Location\":" 718 | out.RawString(prefix) 719 | if in.Location == nil && (out.Flags&jwriter.NilSliceAsEmpty) == 0 { 720 | out.RawString("null") 721 | } else { 722 | out.RawByte('[') 723 | for v30, v31 := range in.Location { 724 | if v30 > 0 { 725 | out.RawByte(',') 726 | } 727 | out.String(string(v31)) 728 | } 729 | out.RawByte(']') 730 | } 731 | } 732 | { 733 | const prefix string = ",\"MD5\":" 734 | out.RawString(prefix) 735 | out.String(string(in.MD5)) 736 | } 737 | out.RawByte('}') 738 | } 739 | 740 | func easyjsonUnmarshalAddress(in *jlexer.Lexer, out *imap.Address) { 741 | isTopLevel := in.IsStart() 742 | if in.IsNull() { 743 | if isTopLevel { 744 | in.Consumed() 745 | } 746 | in.Skip() 747 | return 748 | } 749 | in.Delim('{') 750 | for !in.IsDelim('}') { 751 | key := in.UnsafeString() 752 | in.WantColon() 753 | if in.IsNull() { 754 | in.Skip() 755 | in.WantComma() 756 | continue 757 | } 758 | switch key { 759 | case "PersonalName": 760 | out.PersonalName = string(in.String()) 761 | case "AtDomainList": 762 | out.AtDomainList = string(in.String()) 763 | case "MailboxName": 764 | out.MailboxName = string(in.String()) 765 | case "HostName": 766 | out.HostName = string(in.String()) 767 | default: 768 | in.SkipRecursive() 769 | } 770 | in.WantComma() 771 | } 772 | in.Delim('}') 773 | if isTopLevel { 774 | in.Consumed() 775 | } 776 | } 777 | 778 | func easyjsonMarshalAddress(out *jwriter.Writer, in imap.Address) { 779 | out.RawByte('{') 780 | first := true 781 | _ = first 782 | { 783 | const prefix string = ",\"PersonalName\":" 784 | out.RawString(prefix[1:]) 785 | out.String(string(in.PersonalName)) 786 | } 787 | { 788 | const prefix string = ",\"AtDomainList\":" 789 | out.RawString(prefix) 790 | out.String(string(in.AtDomainList)) 791 | } 792 | { 793 | const prefix string = ",\"MailboxName\":" 794 | out.RawString(prefix) 795 | out.String(string(in.MailboxName)) 796 | } 797 | { 798 | const prefix string = ",\"HostName\":" 799 | out.RawString(prefix) 800 | out.String(string(in.HostName)) 801 | } 802 | out.RawByte('}') 803 | } 804 | -------------------------------------------------------------------------------- /cached_header_easyjson.go: -------------------------------------------------------------------------------- 1 | // Code generated by easyjson for marshaling/unmarshaling. Patched by hand. 2 | 3 | package imapsql 4 | 5 | import ( 6 | "github.com/mailru/easyjson/jlexer" 7 | "github.com/mailru/easyjson/jwriter" 8 | ) 9 | 10 | func easyjsonUnmarshalCachedHeader(in *jlexer.Lexer, out map[string][]string) { 11 | isTopLevel := in.IsStart() 12 | if in.IsNull() { 13 | in.Skip() 14 | } else { 15 | in.Delim('{') 16 | if !in.IsDelim('}') { 17 | out = make(map[string][]string) 18 | } else { 19 | out = nil 20 | } 21 | for !in.IsDelim('}') { 22 | key := string(in.String()) 23 | in.WantColon() 24 | var v1 []string 25 | if in.IsNull() { 26 | in.Skip() 27 | v1 = nil 28 | } else { 29 | in.Delim('[') 30 | if v1 == nil { 31 | if !in.IsDelim(']') { 32 | v1 = make([]string, 0, 4) 33 | } else { 34 | v1 = []string{} 35 | } 36 | } else { 37 | v1 = (v1)[:0] 38 | } 39 | for !in.IsDelim(']') { 40 | var v2 string 41 | v2 = string(in.String()) 42 | v1 = append(v1, v2) 43 | in.WantComma() 44 | } 45 | in.Delim(']') 46 | } 47 | out[key] = v1 48 | in.WantComma() 49 | } 50 | in.Delim('}') 51 | } 52 | if isTopLevel { 53 | in.Consumed() 54 | } 55 | } 56 | func easyjsonMarshalCachedHeader(out *jwriter.Writer, in map[string][]string) { 57 | if in == nil && (out.Flags&jwriter.NilMapAsEmpty) == 0 { 58 | out.RawString(`null`) 59 | } else { 60 | out.RawByte('{') 61 | v3First := true 62 | for v3Name, v3Value := range in { 63 | if v3First { 64 | v3First = false 65 | } else { 66 | out.RawByte(',') 67 | } 68 | out.String(string(v3Name)) 69 | out.RawByte(':') 70 | if v3Value == nil && (out.Flags&jwriter.NilSliceAsEmpty) == 0 { 71 | out.RawString("null") 72 | } else { 73 | out.RawByte('[') 74 | for v4, v5 := range v3Value { 75 | if v4 > 0 { 76 | out.RawByte(',') 77 | } 78 | out.String(string(v5)) 79 | } 80 | out.RawByte(']') 81 | } 82 | } 83 | out.RawByte('}') 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /cmd/imapd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net" 7 | "net/http" 8 | _ "net/http/pprof" 9 | "os" 10 | "os/signal" 11 | "runtime" 12 | 13 | sortthread "github.com/emersion/go-imap-sortthread" 14 | "github.com/emersion/go-imap/server" 15 | imapsql "github.com/foxcpp/go-imap-sql" 16 | ) 17 | 18 | type stdLogger struct{} 19 | 20 | func (s stdLogger) Printf(format string, v ...interface{}) { 21 | log.Printf(format, v...) 22 | } 23 | 24 | func (s stdLogger) Println(v ...interface{}) { 25 | log.Println(v...) 26 | } 27 | 28 | func (s stdLogger) Debugf(format string, v ...interface{}) { 29 | log.Printf("debug: "+format, v...) 30 | } 31 | 32 | func (s stdLogger) Debugln(v ...interface{}) { 33 | v = append([]interface{}{"debug:"}, v...) 34 | log.Println(v...) 35 | } 36 | 37 | func main() { 38 | if len(os.Args) < 5 { 39 | fmt.Fprintf(os.Stderr, "imapd - Dumb IMAP4rev1 server providing unauthenticated access a go-imap-sql db\n") 40 | fmt.Fprintf(os.Stderr, "Usage: %s \n", os.Args[0]) 41 | os.Exit(2) 42 | } 43 | 44 | runtime.SetCPUProfileRate(200) 45 | go http.ListenAndServe("127.0.0.2:9999", nil) 46 | 47 | endpoint := os.Args[1] 48 | driver := os.Args[2] 49 | dsn := os.Args[3] 50 | fsStore := imapsql.FSStore{Root: os.Args[4]} 51 | 52 | bkd, err := imapsql.New(driver, dsn, &fsStore, imapsql.Opts{ 53 | BusyTimeout: 100000, 54 | Log: stdLogger{}, 55 | }) 56 | defer bkd.Close() 57 | if err != nil { 58 | fmt.Fprintf(os.Stderr, "Backend initialization failed: %v\n", err) 59 | os.Exit(2) 60 | } 61 | 62 | srv := server.New(bkd) 63 | defer srv.Close() 64 | 65 | srv.AllowInsecureAuth = true 66 | srv.Enable(sortthread.NewSortExtension()) 67 | srv.Enable(sortthread.NewThreadExtension()) 68 | 69 | l, err := net.Listen("tcp", endpoint) 70 | if err != nil { 71 | fmt.Fprintf(os.Stderr, "%v\n", err) 72 | os.Exit(2) 73 | } 74 | 75 | go func() { 76 | if err := srv.Serve(l); err != nil { 77 | fmt.Fprintf(os.Stderr, "%v\n", err) 78 | } 79 | }() 80 | 81 | sig := make(chan os.Signal, 1) 82 | signal.Notify(sig, os.Interrupt) 83 | 84 | <-sig 85 | } 86 | -------------------------------------------------------------------------------- /cmd/imapsql-ctl/README.md: -------------------------------------------------------------------------------- 1 | imapsql-ctl utility 2 | ------------------- 3 | 4 | Low-level tool for go-imap-sql database management. Minimal wrapper for Backend methods. 5 | 6 | #### --unsafe option 7 | 8 | Per RFC 3501, server must send notifications to clients about any mailboxes 9 | change. Since imapsql-ctl is a low-level tool it doesn't implements any way to 10 | tell server to send such notifications. Most popular SQL RDBMSs don't provide 11 | any means to detect database change and we currently have no plans on 12 | implementing anything for that on go-imap-sql level. 13 | 14 | Therefore, you generally should avoid writting to mailboxes if client who owns 15 | this mailbox is connected to the server. Failure to send required notifications 16 | may result in data damage depending on client implementation. 17 | -------------------------------------------------------------------------------- /cmd/imapsql-ctl/appendlimit.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // Copied from go-imap-backend-tests. 4 | 5 | // AppendLimitUser is extension for backend.User interface which allows to 6 | // set append limit value for testing and administration purposes. 7 | type AppendLimitUser interface { 8 | CreateMessageLimit() *uint32 9 | 10 | // SetMessageLimit sets new value for limit. 11 | // nil pointer means no limit. 12 | SetMessageLimit(val *uint32) error 13 | } 14 | 15 | // AppendLimitMbox is extension for backend.Mailbox interface which allows to 16 | // set append limit value for testing and administration purposes. 17 | type AppendLimitMbox interface { 18 | CreateMessageLimit() *uint32 19 | 20 | // SetMessageLimit sets new value for limit. 21 | // nil pointer means no limit. 22 | SetMessageLimit(val *uint32) error 23 | } 24 | -------------------------------------------------------------------------------- /cmd/imapsql-ctl/flags.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/emersion/go-imap" 7 | "github.com/urfave/cli" 8 | ) 9 | 10 | func msgsFlags(ctx *cli.Context) error { 11 | if err := connectToDB(ctx); err != nil { 12 | return err 13 | } 14 | 15 | if !ctx.GlobalBool("unsafe") { 16 | return errors.New("Error: Refusing to edit mailboxes without --unsafe") 17 | } 18 | 19 | username := ctx.Args().First() 20 | if username == "" { 21 | return errors.New("Error: USERNAME is required") 22 | } 23 | name := ctx.Args().Get(1) 24 | if name == "" { 25 | return errors.New("Error: MAILBOX is required") 26 | } 27 | seqStr := ctx.Args().Get(2) 28 | if seqStr == "" { 29 | return errors.New("Error: SEQ is required") 30 | } 31 | 32 | seq, err := imap.ParseSeqSet(seqStr) 33 | if err != nil { 34 | return err 35 | } 36 | 37 | u, err := backend.GetUser(username) 38 | if err != nil { 39 | return err 40 | } 41 | 42 | _, mbox, err := u.GetMailbox(name, true, nil) 43 | if err != nil { 44 | return err 45 | } 46 | 47 | flags := ctx.Args()[3:] 48 | if len(flags) == 0 { 49 | return errors.New("Error: at least once FLAG is required") 50 | } 51 | 52 | var op imap.FlagsOp 53 | switch ctx.Command.Name { 54 | case "add-flags": 55 | op = imap.AddFlags 56 | case "rem-flags": 57 | op = imap.RemoveFlags 58 | case "set-flags": 59 | op = imap.SetFlags 60 | default: 61 | panic("unknown command: " + ctx.Command.Name) 62 | } 63 | 64 | return mbox.UpdateMessagesFlags(ctx.IsSet("uid"), seq, op, true, flags) 65 | } 66 | -------------------------------------------------------------------------------- /cmd/imapsql-ctl/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "errors" 6 | "fmt" 7 | "os" 8 | 9 | imapsql "github.com/foxcpp/go-imap-sql" 10 | "github.com/urfave/cli" 11 | ) 12 | 13 | var backend *imapsql.Backend 14 | var stdinScnr *bufio.Scanner 15 | 16 | func connectToDB(ctx *cli.Context) error { 17 | if ctx.GlobalIsSet("unsafe") && !ctx.GlobalIsSet("quiet") { 18 | fmt.Fprintln(os.Stderr, "WARNING: Using --unsafe with running server may lead to accidential damage to data due to desynchronization with connected clients.") 19 | } 20 | 21 | driver := ctx.GlobalString("driver") 22 | dsn := ctx.GlobalString("dsn") 23 | fsstore := ctx.GlobalString("fsstore") 24 | 25 | if driver == "" { 26 | return errors.New("Error: driver is required") 27 | } 28 | if dsn == "" { 29 | return errors.New("Error: dsn is required") 30 | } 31 | if fsstore == "" { 32 | return errors.New("Error: fsstrore is required") 33 | } 34 | 35 | opts := imapsql.Opts{} 36 | opts.NoWAL = ctx.GlobalIsSet("no-wal") 37 | 38 | var err error 39 | backend, err = imapsql.New(driver, dsn, &imapsql.FSStore{Root: fsstore}, opts) 40 | if err != nil { 41 | return err 42 | } 43 | 44 | return nil 45 | } 46 | 47 | func closeBackend(ctx *cli.Context) (err error) { 48 | if backend != nil { 49 | return backend.Close() 50 | } 51 | return nil 52 | } 53 | 54 | func main() { 55 | stdinScnr = bufio.NewScanner(os.Stdin) 56 | 57 | app := cli.NewApp() 58 | app.Name = "imapsql-ctl" 59 | app.Copyright = "(c) 2019 Max Mazurov \n Published under the terms of the MIT license (https://opensource.org/licenses/MIT)" 60 | app.Usage = "go-imap-sql database management utility" 61 | app.Version = fmt.Sprintf("%s (go-imap-sql), %d (DB schema)", imapsql.VersionStr, imapsql.SchemaVersion) 62 | app.After = closeBackend 63 | 64 | app.Flags = []cli.Flag{ 65 | cli.StringFlag{ 66 | Name: "driver", 67 | Usage: "SQL driver to use for communication with DB", 68 | EnvVar: "IMASPSQL_DRIVER", 69 | }, 70 | cli.StringFlag{ 71 | Name: "dsn", 72 | Usage: "Data Source Name to use\n\t\tWARNING: Provided only for debugging convenience. Don't leave your passwords in shell history!", 73 | EnvVar: "IMAPSQL_DSN", 74 | }, 75 | cli.BoolFlag{ 76 | Name: "quiet,q", 77 | Usage: "Don't print user-friendly messages to stderr", 78 | }, 79 | cli.BoolFlag{ 80 | Name: "unsafe", 81 | Usage: "Allow to perform actions that can be safely done only without running server", 82 | }, 83 | cli.BoolFlag{ 84 | Name: "allow-schema-upgrade", 85 | Usage: "Allow go-imap-sql to automatically update database schema to version imapsql-ctl is compiled with\n\t\tWARNING: Make a backup before using this flag!", 86 | }, 87 | cli.BoolFlag{ 88 | Name: "no-wal", 89 | Usage: "(SQLite only) Don't force WAL mode", 90 | }, 91 | cli.StringFlag{ 92 | Name: "fsstore", 93 | Usage: "Use fsstore with specified directory", 94 | EnvVar: "IMAPSQL_FSSTORE", 95 | }, 96 | } 97 | 98 | app.Commands = []cli.Command{ 99 | { 100 | Name: "mboxes", 101 | Usage: "Mailboxes (folders) management", 102 | Subcommands: []cli.Command{ 103 | { 104 | Name: "list", 105 | Usage: "Show mailboxes of user", 106 | ArgsUsage: "USERNAME", 107 | Flags: []cli.Flag{ 108 | cli.BoolFlag{ 109 | Name: "subscribed,s", 110 | Usage: "List only subscribed mailboxes", 111 | }, 112 | }, 113 | Action: mboxesList, 114 | }, 115 | { 116 | Name: "create", 117 | Usage: "Create mailbox", 118 | ArgsUsage: "USERNAME NAME", 119 | Action: mboxesCreate, 120 | Flags: []cli.Flag{ 121 | cli.StringFlag{ 122 | Name: "special", 123 | Usage: "Set SPECIAL-USE attribute on mailbox; valid values: archive, drafts, junk, sent, trash", 124 | }, 125 | }, 126 | }, 127 | { 128 | Name: "remove", 129 | Usage: "Remove mailbox (requires --unsafe)", 130 | Description: "WARNING: All contents of mailbox will be irrecoverably lost.", 131 | ArgsUsage: "USERNAME MAILBOX", 132 | Flags: []cli.Flag{ 133 | cli.BoolFlag{ 134 | Name: "yes,y", 135 | Usage: "Don't ask for confirmation", 136 | }, 137 | }, 138 | Action: mboxesRemove, 139 | }, 140 | { 141 | Name: "rename", 142 | Usage: "Rename mailbox (requires --unsafe)", 143 | Description: "Rename may cause unexpected failures on client-side so be careful.", 144 | ArgsUsage: "USERNAME OLDNAME NEWNAME", 145 | Action: mboxesRename, 146 | }, 147 | { 148 | Name: "appendlimit", 149 | Usage: "Query or set user's APPENDLIMIT value", 150 | ArgsUsage: "USERNAME", 151 | Flags: []cli.Flag{ 152 | cli.IntFlag{ 153 | Name: "value,v", 154 | Usage: "Set APPENDLIMIT to specified value (in bytes). Pass -1 to disable limit.", 155 | }, 156 | }, 157 | Action: mboxesAppendLimit, 158 | }, 159 | }, 160 | }, 161 | { 162 | Name: "msgs", 163 | Usage: "Messages management", 164 | Subcommands: []cli.Command{ 165 | { 166 | Name: "add", 167 | Usage: "Add message to mailbox (requires --unsafe)", 168 | ArgsUsage: "USERNAME MAILBOX", 169 | Description: "Reads message body (with headers) from stdin. Prints UID of created message on success.", 170 | Flags: []cli.Flag{ 171 | cli.StringSliceFlag{ 172 | Name: "flag,f", 173 | Usage: "Add flag to message. Can be specified multiple times", 174 | }, 175 | cli.Int64Flag{ 176 | Name: "date,d", 177 | Usage: "Set internal date value to specified UNIX timestamp", 178 | }, 179 | }, 180 | Action: msgsAdd, 181 | }, 182 | { 183 | Name: "add-flags", 184 | Usage: "Add flags to messages (requires --unsafe)", 185 | ArgsUsage: "USERNAME MAILBOX SEQ FLAGS...", 186 | Description: "Add flags to all messages matched by SEQ.", 187 | Flags: []cli.Flag{ 188 | cli.BoolFlag{ 189 | Name: "uid,u", 190 | Usage: "Use UIDs for SEQSET instead of sequence numbers", 191 | }, 192 | }, 193 | Action: msgsFlags, 194 | }, 195 | { 196 | Name: "rem-flags", 197 | Usage: "Remove flags from messages (requires --unsafe)", 198 | ArgsUsage: "USERNAME MAILBOX SEQ FLAGS...", 199 | Description: "Remove flags from all messages matched by SEQ.", 200 | Flags: []cli.Flag{ 201 | cli.BoolFlag{ 202 | Name: "uid,u", 203 | Usage: "Use UIDs for SEQSET instead of sequence numbers", 204 | }, 205 | }, 206 | Action: msgsFlags, 207 | }, 208 | { 209 | Name: "set-flags", 210 | Usage: "Set flags on messages (requires --unsafe)", 211 | ArgsUsage: "USERNAME MAILBOX SEQ FLAGS...", 212 | Description: "Set flags on all messages matched by SEQ.", 213 | Flags: []cli.Flag{ 214 | cli.BoolFlag{ 215 | Name: "uid,u", 216 | Usage: "Use UIDs for SEQSET instead of sequence numbers", 217 | }, 218 | }, 219 | Action: msgsFlags, 220 | }, 221 | { 222 | Name: "remove", 223 | Usage: "Remove messages from mailbox (requires --unsafe)", 224 | ArgsUsage: "USERNAME MAILBOX SEQSET", 225 | Flags: []cli.Flag{ 226 | cli.BoolFlag{ 227 | Name: "uid,u", 228 | Usage: "Use UIDs for SEQSET instead of sequence numbers", 229 | }, 230 | cli.BoolFlag{ 231 | Name: "yes,y", 232 | Usage: "Don't ask for confirmation", 233 | }, 234 | }, 235 | Action: msgsRemove, 236 | }, 237 | { 238 | Name: "copy", 239 | Usage: "Copy messages between mailboxes (requires --unsafe)", 240 | Description: "Note: You can't copy between mailboxes of different users. APPENDLIMIT of target mailbox is not enforced.", 241 | ArgsUsage: "USERNAME SRCMAILBOX SEQSET TGTMAILBOX", 242 | Flags: []cli.Flag{ 243 | cli.BoolFlag{ 244 | Name: "uid,u", 245 | Usage: "Use UIDs for SEQSET instead of sequence numbers", 246 | }, 247 | }, 248 | Action: msgsCopy, 249 | }, 250 | { 251 | Name: "move", 252 | Usage: "Move messages between mailboxes (requires --unsafe)", 253 | Description: "Note: You can't move between mailboxes of different users. APPENDLIMIT of target mailbox is not enforced.", 254 | ArgsUsage: "USERNAME SRCMAILBOX SEQSET TGTMAILBOX", 255 | Flags: []cli.Flag{ 256 | cli.BoolFlag{ 257 | Name: "uid,u", 258 | Usage: "Use UIDs for SEQSET instead of sequence numbers", 259 | }, 260 | }, 261 | Action: msgsMove, 262 | }, 263 | { 264 | Name: "list", 265 | Usage: "List messages in mailbox", 266 | Description: "If SEQSET is specified - only show messages that match it.", 267 | ArgsUsage: "USERNAME MAILBOX [SEQSET]", 268 | Flags: []cli.Flag{ 269 | cli.BoolFlag{ 270 | Name: "uid,u", 271 | Usage: "Use UIDs for SEQSET instead of sequence numbers", 272 | }, 273 | cli.BoolFlag{ 274 | Name: "full,f", 275 | Usage: "Show entire envelope and all server meta-data", 276 | }, 277 | }, 278 | Action: msgsList, 279 | }, 280 | { 281 | Name: "dump", 282 | Usage: "Dump message body", 283 | Description: "If passed SEQ matches multiple messages - they will be joined.", 284 | ArgsUsage: "USERNAME MAILBOX SEQ", 285 | Flags: []cli.Flag{ 286 | cli.BoolFlag{ 287 | Name: "uid,u", 288 | Usage: "Use UIDs for SEQ instead of sequence numbers", 289 | }, 290 | }, 291 | Action: msgsDump, 292 | }, 293 | }, 294 | }, 295 | { 296 | Name: "users", 297 | Usage: "User accounts management", 298 | Subcommands: []cli.Command{ 299 | { 300 | Name: "list", 301 | Usage: "List created user accounts", 302 | Action: usersList, 303 | }, 304 | { 305 | Name: "create", 306 | Usage: "Create user account", 307 | ArgsUsage: "USERNAME", 308 | Action: usersCreate, 309 | }, 310 | { 311 | Name: "remove", 312 | Usage: "Delete user account (requires --unsafe)", 313 | ArgsUsage: "USERNAME", 314 | Flags: []cli.Flag{ 315 | cli.BoolFlag{ 316 | Name: "yes,y", 317 | Usage: "Don't ask for confirmation", 318 | }, 319 | }, 320 | Action: usersRemove, 321 | }, 322 | { 323 | Name: "appendlimit", 324 | Usage: "Query or set user's APPENDLIMIT value", 325 | ArgsUsage: "USERNAME", 326 | Flags: []cli.Flag{ 327 | cli.IntFlag{ 328 | Name: "value,v", 329 | Usage: "Set APPENDLIMIT to specified value (in bytes)", 330 | }, 331 | }, 332 | Action: usersAppendLimit, 333 | }, 334 | }, 335 | }, 336 | } 337 | 338 | if err := app.Run(os.Args); err != nil { 339 | fmt.Fprintln(os.Stderr, err) 340 | } 341 | } 342 | -------------------------------------------------------------------------------- /cmd/imapsql-ctl/mboxes.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "strings" 8 | 9 | eimap "github.com/emersion/go-imap" 10 | imapsql "github.com/foxcpp/go-imap-sql" 11 | "github.com/urfave/cli" 12 | ) 13 | 14 | func mboxesList(ctx *cli.Context) error { 15 | if err := connectToDB(ctx); err != nil { 16 | return err 17 | } 18 | 19 | username := ctx.Args().First() 20 | if username == "" { 21 | return errors.New("Error: USERNAME is required") 22 | } 23 | 24 | u, err := backend.GetUser(username) 25 | if err != nil { 26 | return err 27 | } 28 | 29 | mboxes, err := u.ListMailboxes(ctx.Bool("subscribed,s")) 30 | if err != nil { 31 | return err 32 | } 33 | 34 | if len(mboxes) == 0 && !ctx.GlobalBool("quiet") { 35 | fmt.Fprintln(os.Stderr, "No mailboxes.") 36 | } 37 | 38 | for _, info := range mboxes { 39 | if len(info.Attributes) != 0 { 40 | fmt.Print(info.Name, "\t", info.Attributes, "\n") 41 | } else { 42 | fmt.Println(info.Name) 43 | } 44 | } 45 | 46 | return nil 47 | } 48 | 49 | func mboxesCreate(ctx *cli.Context) error { 50 | if err := connectToDB(ctx); err != nil { 51 | return err 52 | } 53 | 54 | username := ctx.Args().First() 55 | if username == "" { 56 | return errors.New("Error: USERNAME is required") 57 | } 58 | name := ctx.Args().Get(1) 59 | if name == "" { 60 | return errors.New("Error: NAME is required") 61 | } 62 | 63 | u, err := backend.GetUser(username) 64 | if err != nil { 65 | return err 66 | } 67 | 68 | if ctx.IsSet("special") { 69 | attr := "\\" + strings.Title(ctx.String("special")) 70 | return u.(*imapsql.User).CreateMailboxSpecial(name, attr) 71 | } 72 | 73 | return u.CreateMailbox(name) 74 | } 75 | 76 | func mboxesRemove(ctx *cli.Context) error { 77 | if err := connectToDB(ctx); err != nil { 78 | return err 79 | } 80 | 81 | if !ctx.GlobalBool("unsafe") { 82 | return errors.New("Error: Refusing to edit mailboxes without --unsafe") 83 | } 84 | 85 | username := ctx.Args().First() 86 | if username == "" { 87 | return errors.New("Error: USERNAME is required") 88 | } 89 | name := ctx.Args().Get(1) 90 | if name == "" { 91 | return errors.New("Error: NAME is required") 92 | } 93 | 94 | u, err := backend.GetUser(username) 95 | if err != nil { 96 | return err 97 | } 98 | 99 | status, err := u.Status(name, []eimap.StatusItem{eimap.StatusMessages}) 100 | if err != nil { 101 | return err 102 | } 103 | 104 | if !ctx.Bool("yes,y") { 105 | if status.Messages != 0 { 106 | fmt.Fprintf(os.Stderr, "Mailbox %s contains %d messages.\n", name, status.Messages) 107 | } 108 | 109 | if !Confirmation("Are you sure you want to delete that mailbox?", false) { 110 | return errors.New("Cancelled") 111 | } 112 | } 113 | 114 | if err := u.DeleteMailbox(name); err != nil { 115 | return err 116 | } 117 | 118 | return nil 119 | } 120 | 121 | func mboxesRename(ctx *cli.Context) error { 122 | if err := connectToDB(ctx); err != nil { 123 | return err 124 | } 125 | 126 | if !ctx.GlobalBool("unsafe") { 127 | return errors.New("Error: Refusing to edit mailboxes without --unsafe") 128 | } 129 | 130 | username := ctx.Args().First() 131 | if username == "" { 132 | return errors.New("Error: USERNAME is required") 133 | } 134 | oldName := ctx.Args().Get(1) 135 | if oldName == "" { 136 | return errors.New("Error: OLDNAME is required") 137 | } 138 | newName := ctx.Args().Get(2) 139 | if newName == "" { 140 | return errors.New("Error: NEWNAME is required") 141 | } 142 | 143 | u, err := backend.GetUser(username) 144 | if err != nil { 145 | return err 146 | } 147 | 148 | return u.RenameMailbox(oldName, newName) 149 | } 150 | 151 | func mboxesAppendLimit(ctx *cli.Context) error { 152 | if err := connectToDB(ctx); err != nil { 153 | return err 154 | } 155 | 156 | username := ctx.Args().First() 157 | if username == "" { 158 | return errors.New("Error: USERNAME is required") 159 | } 160 | name := ctx.Args().Get(1) 161 | if name == "" { 162 | return errors.New("Error: MAILBOX is required") 163 | } 164 | 165 | u, err := backend.GetUser(username) 166 | if err != nil { 167 | return err 168 | } 169 | 170 | _, mbox, err := u.GetMailbox(name, true, nil) 171 | if err != nil { 172 | return err 173 | } 174 | 175 | mboxAL := mbox.(AppendLimitMbox) 176 | 177 | if ctx.IsSet("value,v") { 178 | val := ctx.Int("value,v") 179 | 180 | var err error 181 | if val == -1 { 182 | err = mboxAL.SetMessageLimit(nil) 183 | } else { 184 | val32 := uint32(val) 185 | err = mboxAL.SetMessageLimit(&val32) 186 | } 187 | if err != nil { 188 | return err 189 | } 190 | } else { 191 | lim := mboxAL.CreateMessageLimit() 192 | if lim == nil { 193 | fmt.Println("No limit") 194 | } else { 195 | fmt.Println(*lim) 196 | } 197 | } 198 | 199 | return nil 200 | } 201 | -------------------------------------------------------------------------------- /cmd/imapsql-ctl/msgs.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "os" 9 | "time" 10 | 11 | eimap "github.com/emersion/go-imap" 12 | imapsql "github.com/foxcpp/go-imap-sql" 13 | "github.com/urfave/cli" 14 | ) 15 | 16 | func msgsAdd(ctx *cli.Context) error { 17 | if err := connectToDB(ctx); err != nil { 18 | return err 19 | } 20 | 21 | if !ctx.GlobalBool("unsafe") { 22 | return errors.New("Error: Refusing to edit mailboxes without --unsafe") 23 | } 24 | 25 | username := ctx.Args().First() 26 | if username == "" { 27 | return errors.New("Error: USERNAME is required") 28 | } 29 | name := ctx.Args().Get(1) 30 | if name == "" { 31 | return errors.New("Error: MAILBOX is required") 32 | } 33 | 34 | u, err := backend.GetUser(username) 35 | if err != nil { 36 | return err 37 | } 38 | 39 | flags := ctx.StringSlice("flag") 40 | if flags == nil { 41 | flags = []string{} 42 | } 43 | 44 | date := time.Now() 45 | if ctx.IsSet("date") { 46 | date = time.Unix(ctx.Int64("date"), 0) 47 | } 48 | 49 | buf := bytes.Buffer{} 50 | if _, err := io.Copy(&buf, os.Stdin); err != nil { 51 | return err 52 | } 53 | 54 | if buf.Len() == 0 { 55 | return errors.New("Error: Empty message, refusing to continue") 56 | } 57 | 58 | status, err := u.Status(name, []eimap.StatusItem{eimap.StatusUidNext}) 59 | if err != nil { 60 | return err 61 | } 62 | 63 | if err := u.CreateMessage(name, flags, date, &buf, nil); err != nil { 64 | return err 65 | } 66 | 67 | fmt.Println(status.UidNext) 68 | 69 | return nil 70 | } 71 | 72 | func msgsRemove(ctx *cli.Context) error { 73 | if err := connectToDB(ctx); err != nil { 74 | return err 75 | } 76 | 77 | if !ctx.GlobalBool("unsafe") { 78 | return errors.New("Error: Refusing to edit mailboxes without --unsafe") 79 | } 80 | 81 | username := ctx.Args().First() 82 | if username == "" { 83 | return errors.New("Error: USERNAME is required") 84 | } 85 | name := ctx.Args().Get(1) 86 | if name == "" { 87 | return errors.New("Error: MAILBOX is required") 88 | } 89 | seqset := ctx.Args().Get(2) 90 | if seqset == "" { 91 | return errors.New("Error: SEQSET is required") 92 | } 93 | 94 | seq, err := eimap.ParseSeqSet(seqset) 95 | if err != nil { 96 | return err 97 | } 98 | 99 | u, err := backend.GetUser(username) 100 | if err != nil { 101 | return err 102 | } 103 | 104 | _, mbox, err := u.GetMailbox(name, false, nil) 105 | if err != nil { 106 | return err 107 | } 108 | 109 | if !ctx.Bool("yes") { 110 | if !Confirmation("Are you sure you want to delete these messages?", false) { 111 | return errors.New("Cancelled") 112 | } 113 | } 114 | 115 | mboxB := mbox.(*imapsql.Mailbox) 116 | if err := mboxB.DelMessages(ctx.Bool("uid"), seq); err != nil { 117 | return err 118 | } 119 | 120 | return nil 121 | } 122 | 123 | func msgsCopy(ctx *cli.Context) error { 124 | if err := connectToDB(ctx); err != nil { 125 | return err 126 | } 127 | 128 | if !ctx.GlobalBool("unsafe") { 129 | return errors.New("Error: Refusing to edit mailboxes without --unsafe") 130 | } 131 | 132 | username := ctx.Args().First() 133 | if username == "" { 134 | return errors.New("Error: USERNAME is required") 135 | } 136 | srcName := ctx.Args().Get(1) 137 | if srcName == "" { 138 | return errors.New("Error: SRCMAILBOX is required") 139 | } 140 | seqset := ctx.Args().Get(2) 141 | if seqset == "" { 142 | return errors.New("Error: SEQSET is required") 143 | } 144 | tgtName := ctx.Args().Get(3) 145 | if tgtName == "" { 146 | return errors.New("Error: TGTMAILBOX is required") 147 | } 148 | 149 | seq, err := eimap.ParseSeqSet(seqset) 150 | if err != nil { 151 | return err 152 | } 153 | 154 | u, err := backend.GetUser(username) 155 | if err != nil { 156 | return err 157 | } 158 | 159 | _, srcMbox, err := u.GetMailbox(srcName, true, nil) 160 | if err != nil { 161 | return err 162 | } 163 | 164 | return srcMbox.CopyMessages(ctx.Bool("uid"), seq, tgtName) 165 | } 166 | 167 | func msgsMove(ctx *cli.Context) error { 168 | if err := connectToDB(ctx); err != nil { 169 | return err 170 | } 171 | 172 | if !ctx.GlobalBool("unsafe") { 173 | return errors.New("Error: Refusing to edit mailboxes without --unsafe") 174 | } 175 | 176 | username := ctx.Args().First() 177 | if username == "" { 178 | return errors.New("Error: USERNAME is required") 179 | } 180 | srcName := ctx.Args().Get(1) 181 | if srcName == "" { 182 | return errors.New("Error: SRCMAILBOX is required") 183 | } 184 | seqset := ctx.Args().Get(2) 185 | if seqset == "" { 186 | return errors.New("Error: SEQSET is required") 187 | } 188 | tgtName := ctx.Args().Get(3) 189 | if tgtName == "" { 190 | return errors.New("Error: TGTMAILBOX is required") 191 | } 192 | 193 | seq, err := eimap.ParseSeqSet(seqset) 194 | if err != nil { 195 | return err 196 | } 197 | 198 | u, err := backend.GetUser(username) 199 | if err != nil { 200 | return err 201 | } 202 | 203 | _, srcMbox, err := u.GetMailbox(srcName, true, nil) 204 | if err != nil { 205 | return err 206 | } 207 | 208 | moveMbox := srcMbox.(*imapsql.Mailbox) 209 | 210 | return moveMbox.MoveMessages(ctx.Bool("uid"), seq, tgtName) 211 | } 212 | 213 | func msgsList(ctx *cli.Context) error { 214 | if err := connectToDB(ctx); err != nil { 215 | return err 216 | } 217 | 218 | username := ctx.Args().First() 219 | if username == "" { 220 | return errors.New("Error: USERNAME is required") 221 | } 222 | mboxName := ctx.Args().Get(1) 223 | if mboxName == "" { 224 | return errors.New("Error: MAILBOX is required") 225 | } 226 | seqset := ctx.Args().Get(2) 227 | if seqset == "" { 228 | seqset = "*" 229 | } 230 | 231 | seq, err := eimap.ParseSeqSet(seqset) 232 | if err != nil { 233 | return err 234 | } 235 | 236 | u, err := backend.GetUser(username) 237 | if err != nil { 238 | return err 239 | } 240 | 241 | _, mbox, err := u.GetMailbox(mboxName, true, nil) 242 | if err != nil { 243 | return err 244 | } 245 | 246 | ch := make(chan *eimap.Message, 10) 247 | go func() { 248 | err = mbox.ListMessages(ctx.Bool("uid"), seq, []eimap.FetchItem{eimap.FetchEnvelope, eimap.FetchInternalDate, eimap.FetchRFC822Size, eimap.FetchFlags, eimap.FetchUid}, ch) 249 | }() 250 | 251 | for msg := range ch { 252 | if !ctx.Bool("full") { 253 | fmt.Printf("UID %d: %s - %s\n %v, %v\n\n", msg.Uid, FormatAddressList(msg.Envelope.From), msg.Envelope.Subject, msg.Flags, msg.Envelope.Date) 254 | continue 255 | } 256 | 257 | fmt.Println("- Server meta-data:") 258 | fmt.Println("UID:", msg.Uid) 259 | fmt.Println("Sequence number:", msg.SeqNum) 260 | fmt.Println("Flags:", msg.Flags) 261 | fmt.Println("Body size:", msg.Size) 262 | fmt.Println("Internal date:", msg.InternalDate.Unix(), msg.InternalDate) 263 | fmt.Println("- Envelope:") 264 | if len(msg.Envelope.From) != 0 { 265 | fmt.Println("From:", FormatAddressList(msg.Envelope.From)) 266 | } 267 | if len(msg.Envelope.To) != 0 { 268 | fmt.Println("To:", FormatAddressList(msg.Envelope.To)) 269 | } 270 | if len(msg.Envelope.Cc) != 0 { 271 | fmt.Println("CC:", FormatAddressList(msg.Envelope.Cc)) 272 | } 273 | if len(msg.Envelope.Bcc) != 0 { 274 | fmt.Println("BCC:", FormatAddressList(msg.Envelope.Bcc)) 275 | } 276 | if msg.Envelope.InReplyTo != "" { 277 | fmt.Println("In-Reply-To:", msg.Envelope.InReplyTo) 278 | } 279 | if msg.Envelope.MessageId != "" { 280 | fmt.Println("Message-Id:", msg.Envelope.MessageId) 281 | } 282 | if !msg.Envelope.Date.IsZero() { 283 | fmt.Println("Date:", msg.Envelope.Date.Unix(), msg.Envelope.Date) 284 | } 285 | if msg.Envelope.Subject != "" { 286 | fmt.Println("Subject:", msg.Envelope.Subject) 287 | } 288 | fmt.Println() 289 | } 290 | return err 291 | } 292 | 293 | func msgsDump(ctx *cli.Context) error { 294 | if err := connectToDB(ctx); err != nil { 295 | return err 296 | } 297 | 298 | username := ctx.Args().First() 299 | if username == "" { 300 | return errors.New("Error: USERNAME is required") 301 | } 302 | mboxName := ctx.Args().Get(1) 303 | if mboxName == "" { 304 | return errors.New("Error: MAILBOX is required") 305 | } 306 | seqset := ctx.Args().Get(2) 307 | if seqset == "" { 308 | seqset = "*" 309 | } 310 | 311 | seq, err := eimap.ParseSeqSet(seqset) 312 | if err != nil { 313 | return err 314 | } 315 | 316 | u, err := backend.GetUser(username) 317 | if err != nil { 318 | return err 319 | } 320 | 321 | _, mbox, err := u.GetMailbox(mboxName, true, nil) 322 | if err != nil { 323 | return err 324 | } 325 | 326 | ch := make(chan *eimap.Message, 10) 327 | go func() { 328 | err = mbox.ListMessages(ctx.Bool("uid"), seq, []eimap.FetchItem{eimap.FetchRFC822}, ch) 329 | }() 330 | 331 | for msg := range ch { 332 | for _, v := range msg.Body { 333 | if _, err := io.Copy(os.Stdout, v); err != nil { 334 | return err 335 | } 336 | } 337 | } 338 | return err 339 | } 340 | -------------------------------------------------------------------------------- /cmd/imapsql-ctl/mysql.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import _ "github.com/go-sql-driver/mysql" 4 | -------------------------------------------------------------------------------- /cmd/imapsql-ctl/postgresql.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import _ "github.com/lib/pq" 4 | -------------------------------------------------------------------------------- /cmd/imapsql-ctl/sqlite3.go: -------------------------------------------------------------------------------- 1 | // +build cgo 2 | 3 | package main 4 | 5 | import _ "github.com/mattn/go-sqlite3" 6 | -------------------------------------------------------------------------------- /cmd/imapsql-ctl/termios.go: -------------------------------------------------------------------------------- 1 | //+build linux 2 | 3 | package main 4 | 5 | // Copied from github.com/foxcpp/ttyprompt 6 | // Commit 087a574, terminal/termios.go 7 | 8 | import ( 9 | "errors" 10 | "os" 11 | "syscall" 12 | "unsafe" 13 | ) 14 | 15 | type Termios struct { 16 | Iflag uint32 17 | Oflag uint32 18 | Cflag uint32 19 | Lflag uint32 20 | Cc [20]byte 21 | Ispeed uint32 22 | Ospeed uint32 23 | } 24 | 25 | /* 26 | TurnOnRawIO sets flags suitable for raw I/O (no echo, per-character input, etc) 27 | and returns original flags. 28 | */ 29 | func TurnOnRawIO(tty *os.File) (orig Termios, err error) { 30 | termios, err := TcGetAttr(tty.Fd()) 31 | if err != nil { 32 | return Termios{}, errors.New("TurnOnRawIO: failed to get flags: " + err.Error()) 33 | } 34 | termiosOrig := *termios 35 | 36 | termios.Lflag &^= syscall.ECHO 37 | termios.Lflag &^= syscall.ICANON 38 | termios.Iflag &^= syscall.IXON 39 | termios.Lflag &^= syscall.ISIG 40 | termios.Iflag |= syscall.IUTF8 41 | err = TcSetAttr(tty.Fd(), termios) 42 | if err != nil { 43 | return Termios{}, errors.New("TurnOnRawIO: flags to set flags: " + err.Error()) 44 | } 45 | return termiosOrig, nil 46 | } 47 | 48 | func TcSetAttr(fd uintptr, termios *Termios) error { 49 | _, _, err := syscall.Syscall(syscall.SYS_IOCTL, fd, syscall.TCSETS, uintptr(unsafe.Pointer(termios))) 50 | if err != 0 { 51 | return err 52 | } 53 | return nil 54 | } 55 | 56 | func TcGetAttr(fd uintptr) (*Termios, error) { 57 | termios := &Termios{} 58 | _, _, err := syscall.Syscall(syscall.SYS_IOCTL, fd, syscall.TCGETS, uintptr(unsafe.Pointer(termios))) 59 | if err != 0 { 60 | return nil, err 61 | } 62 | return termios, nil 63 | } 64 | -------------------------------------------------------------------------------- /cmd/imapsql-ctl/termios_stub.go: -------------------------------------------------------------------------------- 1 | //+build !linux 2 | 3 | package main 4 | 5 | import ( 6 | "errors" 7 | "os" 8 | ) 9 | 10 | type Termios struct { 11 | Iflag uint32 12 | Oflag uint32 13 | Cflag uint32 14 | Lflag uint32 15 | Cc [20]byte 16 | Ispeed uint32 17 | Ospeed uint32 18 | } 19 | 20 | func TurnOnRawIO(tty *os.File) (orig Termios, err error) { 21 | return Termios{}, errors.New("not implemented") 22 | } 23 | 24 | func TcSetAttr(fd uintptr, termios *Termios) error { 25 | return errors.New("not implemented") 26 | } 27 | 28 | func TcGetAttr(fd uintptr) (*Termios, error) { 29 | return nil, errors.New("not implemented") 30 | } 31 | -------------------------------------------------------------------------------- /cmd/imapsql-ctl/users.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/urfave/cli" 9 | ) 10 | 11 | func usersList(ctx *cli.Context) error { 12 | if err := connectToDB(ctx); err != nil { 13 | return err 14 | } 15 | 16 | list, err := backend.ListUsers() 17 | if err != nil { 18 | return err 19 | } 20 | 21 | if len(list) == 0 && !ctx.GlobalBool("quiet") { 22 | fmt.Fprintln(os.Stderr, "No users.") 23 | } 24 | 25 | for _, user := range list { 26 | fmt.Println(user) 27 | } 28 | return nil 29 | } 30 | 31 | func usersCreate(ctx *cli.Context) error { 32 | if err := connectToDB(ctx); err != nil { 33 | return err 34 | } 35 | 36 | username := ctx.Args().First() 37 | if username == "" { 38 | return errors.New("Error: USERNAME is required") 39 | } 40 | 41 | _, err := backend.GetUser(username) 42 | if err == nil { 43 | return errors.New("Error: User already exists") 44 | } 45 | 46 | return backend.CreateUser(username) 47 | } 48 | 49 | func usersRemove(ctx *cli.Context) error { 50 | if err := connectToDB(ctx); err != nil { 51 | return err 52 | } 53 | 54 | if !ctx.GlobalBool("unsafe") { 55 | return errors.New("Error: Refusing to edit mailboxes without --unsafe") 56 | } 57 | 58 | username := ctx.Args().First() 59 | if username == "" { 60 | return errors.New("Error: USERNAME is required") 61 | } 62 | 63 | _, err := backend.GetUser(username) 64 | if err != nil { 65 | return errors.New("Error: User doesn't exists") 66 | } 67 | 68 | if !ctx.Bool("yes") { 69 | if !Confirmation("Are you sure you want to delete this user account?", false) { 70 | return errors.New("Cancelled") 71 | } 72 | } 73 | 74 | return backend.DeleteUser(username) 75 | } 76 | 77 | func usersAppendLimit(ctx *cli.Context) error { 78 | if err := connectToDB(ctx); err != nil { 79 | return err 80 | } 81 | 82 | username := ctx.Args().First() 83 | if username == "" { 84 | return errors.New("Error: USERNAME is required") 85 | } 86 | 87 | u, err := backend.GetUser(username) 88 | if err != nil { 89 | return err 90 | } 91 | userAL := u.(AppendLimitUser) 92 | 93 | if ctx.IsSet("value") { 94 | val := ctx.Int("value") 95 | 96 | var err error 97 | if val == -1 { 98 | err = userAL.SetMessageLimit(nil) 99 | } else { 100 | val32 := uint32(val) 101 | err = userAL.SetMessageLimit(&val32) 102 | } 103 | if err != nil { 104 | return err 105 | } 106 | } else { 107 | lim := userAL.CreateMessageLimit() 108 | if lim == nil { 109 | fmt.Println("No limit") 110 | } else { 111 | fmt.Println(*lim) 112 | } 113 | } 114 | 115 | return nil 116 | } 117 | -------------------------------------------------------------------------------- /cmd/imapsql-ctl/utils.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "strings" 8 | 9 | "github.com/emersion/go-imap" 10 | eimap "github.com/emersion/go-imap" 11 | ) 12 | 13 | func FormatAddress(addr *eimap.Address) string { 14 | return fmt.Sprintf("%s <%s@%s>", addr.PersonalName, addr.MailboxName, addr.HostName) 15 | } 16 | 17 | func FormatAddressList(addrs []*imap.Address) string { 18 | res := make([]string, 0, len(addrs)) 19 | for _, addr := range addrs { 20 | res = append(res, FormatAddress(addr)) 21 | } 22 | return strings.Join(res, ", ") 23 | } 24 | 25 | func Confirmation(prompt string, def bool) bool { 26 | selection := "y/N" 27 | if def { 28 | selection = "Y/n" 29 | } 30 | 31 | fmt.Fprintf(os.Stderr, "%s [%s]: ", prompt, selection) 32 | if !stdinScnr.Scan() { 33 | fmt.Fprintln(os.Stderr, stdinScnr.Err()) 34 | return false 35 | } 36 | 37 | switch stdinScnr.Text() { 38 | case "Y", "y": 39 | return true 40 | case "N", "n": 41 | return false 42 | default: 43 | return def 44 | } 45 | } 46 | 47 | func readPass(tty *os.File, output []byte) ([]byte, error) { 48 | cursor := output[0:1] 49 | readen := 0 50 | for { 51 | n, err := tty.Read(cursor) 52 | if n != 1 { 53 | return nil, errors.New("ReadPassword: invalid read size when not in canonical mode") 54 | } 55 | if err != nil { 56 | return nil, errors.New("ReadPassword: " + err.Error()) 57 | } 58 | if cursor[0] == '\n' { 59 | break 60 | } 61 | // Esc or Ctrl+D or Ctrl+C. 62 | if cursor[0] == '\x1b' || cursor[0] == '\x04' || cursor[0] == '\x03' { 63 | return nil, errors.New("ReadPassword: prompt rejected") 64 | } 65 | if cursor[0] == '\x7F' /* DEL */ { 66 | if readen != 0 { 67 | readen-- 68 | cursor = output[readen : readen+1] 69 | } 70 | continue 71 | } 72 | 73 | if readen == cap(output) { 74 | return nil, errors.New("ReadPassword: too long password") 75 | } 76 | 77 | readen++ 78 | cursor = output[readen : readen+1] 79 | } 80 | 81 | return output[0:readen], nil 82 | } 83 | 84 | func ReadPassword(prompt string) (string, error) { 85 | termios, err := TurnOnRawIO(os.Stdin) 86 | hiddenPass := true 87 | if err != nil { 88 | hiddenPass = false 89 | fmt.Fprintln(os.Stderr, "Failed to disable terminal output:", err) 90 | } 91 | defer TcSetAttr(os.Stdin.Fd(), &termios) 92 | 93 | fmt.Fprintf(os.Stderr, "%s: ", prompt) 94 | 95 | if hiddenPass { 96 | buf := make([]byte, 512) 97 | buf, err = readPass(os.Stdin, buf) 98 | if err != nil { 99 | return "", err 100 | } 101 | fmt.Println() 102 | 103 | return string(buf), nil 104 | } else { 105 | if !stdinScnr.Scan() { 106 | return "", stdinScnr.Err() 107 | } 108 | 109 | return stdinScnr.Text(), nil 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /compress.go: -------------------------------------------------------------------------------- 1 | package imapsql 2 | 3 | import ( 4 | "io" 5 | "strconv" 6 | 7 | "github.com/klauspost/compress/zstd" 8 | "github.com/pierrec/lz4" 9 | ) 10 | 11 | type CompressionAlgo interface { 12 | // WrapCompress wraps writer such that any data written to it 13 | // will be compressed using a certain compression algorithms. 14 | // 15 | // Close on returned writer should not close original writer, but 16 | // should flush any buffers if necessary. 17 | // 18 | // Algorithm settings can be customized by passing 19 | // implementation-defined params argument. Most algorithms 20 | // will include compression level here as a string. More complex 21 | // algorithms can use JSON to store complex settings. Empty string 22 | // means that the default parameters should be used. 23 | WrapCompress(w io.Writer, params string) (io.WriteCloser, error) 24 | 25 | // WrapDecompress wraps writer such that underlying stream should be decompressed 26 | // using a certain compression algorithms. 27 | WrapDecompress(r io.Reader) (io.Reader, error) 28 | } 29 | 30 | var compressionAlgos = map[string]CompressionAlgo{ 31 | "": nullCompression{}, 32 | "lz4": lz4Compression{}, 33 | "zstd": zstdCompression{}, 34 | } 35 | 36 | // RegisterCompressionAlgo adds a new compression algorithm to the registry so it can 37 | // be used in Opts.CompressionAlgo. 38 | func RegisterCompressionAlgo(name string, algo CompressionAlgo) { 39 | compressionAlgos[name] = algo 40 | } 41 | 42 | type lz4Compression struct{} 43 | 44 | func (algo lz4Compression) WrapCompress(w io.Writer, params string) (io.WriteCloser, error) { 45 | lz4w := lz4.NewWriter(w) 46 | if params != "" { 47 | var err error 48 | lz4w.CompressionLevel, err = strconv.Atoi(params) 49 | if err != nil { 50 | return nil, err 51 | } 52 | } 53 | return lz4w, nil 54 | } 55 | 56 | func (algo lz4Compression) WrapDecompress(r io.Reader) (io.Reader, error) { 57 | return lz4.NewReader(r), nil 58 | } 59 | 60 | type zstdCompression struct{} 61 | 62 | func (algo zstdCompression) WrapCompress(w io.Writer, params string) (io.WriteCloser, error) { 63 | encoderLvl := zstd.SpeedDefault 64 | if params != "" { 65 | zstdLevel, err := strconv.Atoi(params) 66 | if err != nil { 67 | return nil, err 68 | } 69 | encoderLvl = zstd.EncoderLevelFromZstd(zstdLevel) 70 | } 71 | return zstd.NewWriter(w, zstd.WithEncoderLevel(encoderLvl)) 72 | } 73 | 74 | func (algo zstdCompression) WrapDecompress(r io.Reader) (io.Reader, error) { 75 | return zstd.NewReader(r) 76 | } 77 | 78 | type nullCompression struct{} 79 | 80 | func (algo nullCompression) WrapCompress(w io.Writer, params string) (io.WriteCloser, error) { 81 | return nopCloser{w}, nil 82 | } 83 | 84 | func (algo nullCompression) WrapDecompress(r io.Reader) (io.Reader, error) { 85 | return r, nil 86 | } 87 | -------------------------------------------------------------------------------- /date.go: -------------------------------------------------------------------------------- 1 | package imapsql 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "time" 7 | ) 8 | 9 | // copied from https://github.com/emersion/go-imap/blob/09c1d69/date.go 10 | 11 | // Date and time layouts. 12 | const ( 13 | // Defined in RFC 5322 section 3.3, mentioned as env-date in RFC 3501 page 84. 14 | envelopeDateTimeLayout = "Mon, 02 Jan 2006 15:04:05 -0700" 15 | ) 16 | 17 | // Permutations of the layouts defined in RFC 5322, section 3.3. 18 | var envelopeDateTimeLayouts = [...]string{ 19 | envelopeDateTimeLayout, // popular, try it first 20 | "_2 Jan 2006 15:04:05 -0700", 21 | "_2 Jan 2006 15:04:05 MST", 22 | "_2 Jan 2006 15:04 -0700", 23 | "_2 Jan 2006 15:04 MST", 24 | "_2 Jan 06 15:04:05 -0700", 25 | "_2 Jan 06 15:04:05 MST", 26 | "_2 Jan 06 15:04 -0700", 27 | "_2 Jan 06 15:04 MST", 28 | "Mon, _2 Jan 2006 15:04:05 -0700", 29 | "Mon, _2 Jan 2006 15:04:05 MST", 30 | "Mon, _2 Jan 2006 15:04 -0700", 31 | "Mon, _2 Jan 2006 15:04 MST", 32 | "Mon, _2 Jan 06 15:04:05 -0700", 33 | "Mon, _2 Jan 06 15:04:05 MST", 34 | "Mon, _2 Jan 06 15:04 -0700", 35 | "Mon, _2 Jan 06 15:04 MST", 36 | } 37 | 38 | // TODO: this is a blunt way to strip any trailing CFWS (comment). A sharper 39 | // one would strip multiple CFWS, and only if really valid according to 40 | // RFC5322. 41 | var commentRE = regexp.MustCompile(`[ \t]+\(.*\)$`) 42 | 43 | // Try parsing the date based on the layouts defined in RFC 5322, section 3.3. 44 | // Inspired by https://github.com/golang/go/blob/master/src/net/mail/message.go 45 | func parseMessageDateTime(maybeDate string) (time.Time, error) { 46 | maybeDate = commentRE.ReplaceAllString(maybeDate, "") 47 | for _, layout := range envelopeDateTimeLayouts { 48 | parsed, err := time.Parse(layout, maybeDate) 49 | if err == nil { 50 | return parsed, nil 51 | } 52 | } 53 | return time.Time{}, fmt.Errorf("date %s could not be parsed", maybeDate) 54 | } 55 | -------------------------------------------------------------------------------- /dbhacks.go: -------------------------------------------------------------------------------- 1 | package imapsql 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "strconv" 7 | "strings" 8 | ) 9 | 10 | // db struct is a thin wrapper to solve the most annoying problems 11 | // with cross-RDBMS compatibility. 12 | type db struct { 13 | DB *sql.DB 14 | driver string 15 | dsn string 16 | } 17 | 18 | func (d db) Prepare(req string) (*sql.Stmt, error) { 19 | return d.DB.Prepare(d.rewriteSQL(req)) 20 | } 21 | 22 | func (d db) Query(req string, args ...interface{}) (*sql.Rows, error) { 23 | return d.DB.Query(d.rewriteSQL(req), args...) 24 | } 25 | 26 | func (d db) QueryRow(req string, args ...interface{}) *sql.Row { 27 | return d.DB.QueryRow(d.rewriteSQL(req), args...) 28 | } 29 | 30 | func (d db) Exec(req string, args ...interface{}) (sql.Result, error) { 31 | return d.DB.Exec(d.rewriteSQL(req), args...) 32 | } 33 | 34 | func (d db) Begin(readOnly bool) (*sql.Tx, error) { 35 | return d.DB.BeginTx(context.TODO(), &sql.TxOptions{ 36 | Isolation: sql.LevelRepeatableRead, 37 | ReadOnly: readOnly, 38 | }) 39 | } 40 | 41 | func (d db) BeginLevel(isolation sql.IsolationLevel, readOnly bool) (*sql.Tx, error) { 42 | return d.DB.BeginTx(context.TODO(), &sql.TxOptions{ 43 | Isolation: isolation, 44 | ReadOnly: readOnly, 45 | }) 46 | } 47 | 48 | func (d db) Close() error { 49 | return d.DB.Close() 50 | } 51 | 52 | func (d db) rewriteSQL(req string) (res string) { 53 | res = strings.TrimSpace(req) 54 | res = strings.TrimLeft(res, "\n\t") 55 | if d.driver == "postgres" { 56 | res = "" 57 | placeholderIndx := 1 58 | for _, chr := range req { 59 | if chr == '?' { 60 | res += "$" + strconv.Itoa(placeholderIndx) 61 | placeholderIndx += 1 62 | } else { 63 | res += string(chr) 64 | } 65 | } 66 | res = strings.TrimLeft(res, "\n\t") 67 | if strings.HasPrefix(res, "CREATE TABLE") || strings.HasPrefix(res, "ALTER TABLE") { 68 | res = strings.Replace(res, "BLOB", "BYTEA", -1) 69 | res = strings.Replace(res, "LONGTEXT", "BYTEA", -1) 70 | res = strings.Replace(res, "AUTOINCREMENT", "", -1) 71 | } 72 | } else if d.driver == "mysql" { 73 | if strings.HasPrefix(res, "CREATE TABLE") || strings.HasPrefix(res, "ALTER TABLE") { 74 | res = strings.Replace(res, "BIGSERIAL", "BIGINT", -1) 75 | res = strings.Replace(res, "AUTOINCREMENT", "AUTO_INCREMENT", -1) 76 | } 77 | if strings.HasSuffix(res, "ON CONFLICT DO NOTHING") && strings.HasPrefix(res, "INSERT") { 78 | res = strings.Replace(res, "ON CONFLICT DO NOTHING", "", -1) 79 | res = strings.Replace(res, "INSERT", "INSERT IGNORE", 1) 80 | } 81 | } else if d.driver == "sqlite3" || d.driver == "sqlite" { 82 | if strings.HasPrefix(res, "CREATE TABLE") || strings.HasPrefix(res, "ALTER TABLE") { 83 | res = strings.Replace(res, "BIGSERIAL", "INTEGER", -1) 84 | } 85 | if strings.HasSuffix(res, "ON CONFLICT DO NOTHING") && strings.HasPrefix(res, "INSERT") { 86 | res = strings.Replace(res, "ON CONFLICT DO NOTHING", "", -1) 87 | res = strings.Replace(res, "INSERT", "INSERT OR IGNORE", 1) 88 | } 89 | // SQLite3 got no notion of locking and always uses Serialized Isolation. 90 | if strings.HasPrefix(res, "SELECT") { 91 | res = strings.Replace(res, "FOR UPDATE", "", -1) 92 | } 93 | } 94 | 95 | //log.Println(res) 96 | 97 | return 98 | } 99 | 100 | func (db db) valuesSubquery(flagsCount int) string { 101 | sqlList := "" 102 | if db.driver == "mysql" { 103 | 104 | sqlList += "SELECT ? AS column1" 105 | for i := 1; i < flagsCount; i++ { 106 | sqlList += " UNION ALL SELECT ? " 107 | } 108 | 109 | return sqlList 110 | } 111 | 112 | for i := 0; i < flagsCount; i++ { 113 | if db.driver == "postgres" { 114 | sqlList += "(?::text)" // query rewriter will make it into $N::text. 115 | // This is a workaround for CockroachDB's https://github.com/cockroachdb/cockroach/issues/41558 116 | } else { 117 | sqlList += "(?)" 118 | } 119 | if i+1 != flagsCount { 120 | sqlList += "," 121 | } 122 | } 123 | 124 | return "VALUES " + sqlList 125 | } 126 | 127 | func (db db) aggrValuesSet(expr, separator string) string { 128 | if db.driver == "sqlite3" || db.driver == "sqlite" { 129 | return "coalesce(group_concat(" + expr + ", '" + separator + "'), '')" 130 | } 131 | if db.driver == "postgres" { 132 | return "coalesce(string_agg(" + expr + ",'" + separator + "'), '')" 133 | } 134 | if db.driver == "mysql" { 135 | return "coalesce(group_concat(" + expr + " SEPARATOR '" + separator + "'), '')" 136 | } 137 | panic("Unsupported driver") 138 | } 139 | -------------------------------------------------------------------------------- /delivery.go: -------------------------------------------------------------------------------- 1 | package imapsql 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "database/sql" 7 | "errors" 8 | "io" 9 | "io/ioutil" 10 | "time" 11 | 12 | "github.com/emersion/go-imap/backend" 13 | "github.com/emersion/go-message/textproto" 14 | ) 15 | 16 | var ErrDeliveryInterrupted = errors.New("sql: delivery transaction interrupted, try again later") 17 | 18 | // NewDelivery creates a new state object for atomic delivery session. 19 | // 20 | // Messages added to the storage using that interface are added either to 21 | // all recipients mailboxes or none or them. 22 | // 23 | // Also use of this interface is more efficient than separate GetUser/GetMailbox/CreateMessage 24 | // calls. 25 | // 26 | // Note that for performance reasons, the DB is not locked while the Delivery object 27 | // exists, but only when BodyRaw/BodyParsed is called and until Abort/Commit is called. 28 | // This means that the recipient mailbox can be deleted between AddRcpt and Body* calls. 29 | // In that case, either Body* or Commit will return ErrDeliveryInterrupt. 30 | // Sender should retry delivery after a short delay. 31 | func (b *Backend) NewDelivery() Delivery { 32 | return Delivery{b: b, perRcptHeader: map[string]textproto.Header{}} 33 | } 34 | 35 | func (d *Delivery) clean() { 36 | d.users = d.users[0:0] 37 | d.mboxes = d.mboxes[0:0] 38 | d.extKey = "" 39 | for k := range d.perRcptHeader { 40 | delete(d.perRcptHeader, k) 41 | } 42 | } 43 | 44 | type Delivery struct { 45 | b *Backend 46 | tx *sql.Tx 47 | users []User 48 | mboxes []Mailbox 49 | extKey string 50 | perRcptHeader map[string]textproto.Header 51 | flagOverrides map[string][]string 52 | mboxOverrides map[string]string 53 | } 54 | 55 | // AddRcpt adds the recipient username/mailbox pair to the delivery. 56 | // 57 | // If this function returns an error - further calls will still work 58 | // correctly and there is no need to restart the delivery. 59 | // 60 | // The specified user account and mailbox should exist at the time AddRcpt 61 | // is called, but it can disappear before Body* call, in which case 62 | // Delivery will be terminated with ErrDeliveryInterrupted error. 63 | // See Backend.StartDelivery method documentation for details. 64 | // 65 | // Fields from userHeader, if any, will be prepended to the message header 66 | // *only* for that recipient. Use this to add Received and Delivered-To 67 | // fields with recipient-specific information (e.g. its address). 68 | func (d *Delivery) AddRcpt(username string, userHeader textproto.Header) error { 69 | username = normalizeUsername(username) 70 | 71 | uid, inboxId, err := d.b.getUserMeta(nil, username) 72 | if err != nil { 73 | if err == sql.ErrNoRows { 74 | return ErrUserDoesntExists 75 | } 76 | return err 77 | } 78 | d.users = append(d.users, User{id: uid, username: username, parent: d.b, inboxId: inboxId}) 79 | 80 | d.perRcptHeader[username] = userHeader 81 | 82 | return nil 83 | } 84 | 85 | // FIXME: Fix that goddamned code duplication. 86 | 87 | // Mailbox command changes the target mailbox for all recipients. 88 | // It should be called before BodyParsed/BodyRaw. 89 | // 90 | // If it is not called, it defaults to INBOX. If mailbox doesn't 91 | // exist for some users - it will created. 92 | func (d *Delivery) Mailbox(name string) error { 93 | if cap(d.mboxes) < len(d.users) { 94 | d.mboxes = make([]Mailbox, 0, len(d.users)) 95 | } 96 | 97 | for _, u := range d.users { 98 | if mboxName := d.mboxOverrides[u.username]; mboxName != "" { 99 | _, mbox, err := u.GetMailbox(mboxName, true, nil) 100 | if err == nil { 101 | d.mboxes = append(d.mboxes, *mbox.(*Mailbox)) 102 | continue 103 | } 104 | } 105 | 106 | _, mbox, err := u.GetMailbox(name, true, nil) 107 | if err != nil { 108 | if err != backend.ErrNoSuchMailbox { 109 | d.mboxes = nil 110 | return err 111 | } 112 | 113 | if err := u.CreateMailbox(name); err != nil && err != backend.ErrMailboxAlreadyExists { 114 | d.mboxes = nil 115 | return err 116 | } 117 | 118 | _, mbox, err = u.GetMailbox(name, true, nil) 119 | if err != nil { 120 | d.mboxes = nil 121 | return err 122 | } 123 | } 124 | 125 | d.mboxes = append(d.mboxes, *mbox.(*Mailbox)) 126 | } 127 | return nil 128 | } 129 | 130 | // SpecialMailbox is similar to Mailbox method but instead of looking up mailboxes 131 | // by name it looks it up by the SPECIAL-USE attribute. 132 | // 133 | // If no such mailbox exists for some user, it will be created with 134 | // fallbackName and requested SPECIAL-USE attribute set. 135 | // 136 | // The main use-case of this function is to reroute messages into Junk directory 137 | // during multi-recipient delivery. 138 | func (d *Delivery) SpecialMailbox(attribute, fallbackName string) error { 139 | if cap(d.mboxes) < len(d.users) { 140 | d.mboxes = make([]Mailbox, 0, len(d.users)) 141 | } 142 | for _, u := range d.users { 143 | if mboxName := d.mboxOverrides[u.username]; mboxName != "" { 144 | _, mbox, err := u.GetMailbox(mboxName, true, nil) 145 | if err == nil { 146 | d.mboxes = append(d.mboxes, *mbox.(*Mailbox)) 147 | continue 148 | } 149 | } 150 | 151 | var mboxId uint64 152 | var mboxName string 153 | err := d.b.specialUseMbox.QueryRow(u.id, attribute).Scan(&mboxName, &mboxId) 154 | if err != nil { 155 | if err != sql.ErrNoRows { 156 | d.mboxes = nil 157 | return err 158 | } 159 | 160 | if err := u.CreateMailboxSpecial(fallbackName, attribute); err != nil && err != backend.ErrMailboxAlreadyExists { 161 | d.mboxes = nil 162 | return err 163 | } 164 | 165 | _, mbox, err := u.GetMailbox(fallbackName, true, nil) 166 | if err != nil { 167 | d.mboxes = nil 168 | return err 169 | } 170 | d.mboxes = append(d.mboxes, *mbox.(*Mailbox)) 171 | continue 172 | } 173 | 174 | d.mboxes = append(d.mboxes, Mailbox{user: u, id: mboxId, name: mboxName, parent: d.b}) 175 | } 176 | return nil 177 | } 178 | 179 | func (d *Delivery) UserMailbox(username, mailbox string, flags []string) { 180 | if d.mboxOverrides == nil { 181 | d.mboxOverrides = make(map[string]string) 182 | } 183 | if d.flagOverrides == nil { 184 | d.flagOverrides = make(map[string][]string) 185 | } 186 | 187 | d.mboxOverrides[username] = mailbox 188 | d.flagOverrides[username] = flags 189 | } 190 | 191 | type memoryBuffer struct { 192 | slice []byte 193 | } 194 | 195 | func (mb memoryBuffer) Open() (io.ReadCloser, error) { 196 | return ioutil.NopCloser(bytes.NewReader(mb.slice)), nil 197 | } 198 | 199 | // BodyRaw is convenience wrapper for BodyParsed. Use it only for most simple cases (e.g. for tests). 200 | // 201 | // You want to use BodyParsed in most cases. It is much more efficient. BodyRaw reads the entire message 202 | // into memory. 203 | func (d *Delivery) BodyRaw(message io.Reader) error { 204 | bufferedMsg := bufio.NewReader(message) 205 | hdr, err := textproto.ReadHeader(bufferedMsg) 206 | if err != nil { 207 | return err 208 | } 209 | 210 | blob, err := ioutil.ReadAll(bufferedMsg) 211 | if err != nil { 212 | return err 213 | } 214 | 215 | return d.BodyParsed(hdr, len(blob), memoryBuffer{slice: blob}) 216 | } 217 | 218 | // Buffer is the temporary storage for the message body. 219 | type Buffer interface { 220 | Open() (io.ReadCloser, error) 221 | } 222 | 223 | func (d *Delivery) BodyParsed(header textproto.Header, bodyLen int, body Buffer) error { 224 | if len(d.mboxes) == 0 { 225 | if err := d.Mailbox("INBOX"); err != nil { 226 | return err 227 | } 228 | } 229 | 230 | // Make sure all auto-generated statements are generated before we start transaction 231 | // so it will not cause deadlocks on SQlite when statement is prepared outside 232 | // of transaction while transaction is running. 233 | for _, mbox := range d.mboxes { 234 | if len(d.flagOverrides[mbox.user.username]) != 0 { 235 | _, err := d.b.getFlagsAddStmt(len(d.flagOverrides[mbox.user.username])) 236 | if err != nil { 237 | return wrapErr(err, "Body") 238 | } 239 | } 240 | } 241 | 242 | date := time.Now() 243 | 244 | var err error 245 | d.tx, err = d.b.db.BeginLevel(sql.LevelReadCommitted, false) 246 | if err != nil { 247 | return wrapErr(err, "Body") 248 | } 249 | 250 | for _, mbox := range d.mboxes { 251 | var flagsStmt *sql.Stmt 252 | if len(d.flagOverrides[mbox.user.username]) != 0 { 253 | flagsStmt, err = d.b.getFlagsAddStmt(len(d.flagOverrides[mbox.user.username])) 254 | if err != nil { 255 | return wrapErr(err, "Body") 256 | } 257 | } 258 | 259 | err = d.mboxDelivery(header, mbox, int64(bodyLen), body, date, flagsStmt) 260 | if err != nil { 261 | return err 262 | } 263 | } 264 | 265 | return nil 266 | } 267 | 268 | func (d *Delivery) mboxDelivery(header textproto.Header, mbox Mailbox, bodyLen int64, body Buffer, date time.Time, flagsStmt *sql.Stmt) (err error) { 269 | header = header.Copy() 270 | userHeader := d.perRcptHeader[mbox.user.username] 271 | for fields := userHeader.Fields(); fields.Next(); { 272 | header.Add(fields.Key(), fields.Value()) 273 | } 274 | 275 | headerBlob := bytes.Buffer{} 276 | if err := textproto.WriteHeader(&headerBlob, header); err != nil { 277 | return wrapErr(err, "Body (WriteHeader)") 278 | } 279 | 280 | length := int64(headerBlob.Len()) + bodyLen 281 | bodyReader, err := body.Open() 282 | if err != nil { 283 | return err 284 | } 285 | 286 | bodyStruct, cachedHeader, extBodyKey, err := d.b.processParsedBody(headerBlob.Bytes(), header, bodyReader, bodyLen) 287 | if err != nil { 288 | return err 289 | } 290 | 291 | if _, err = d.tx.Stmt(d.b.addExtKey).Exec(extBodyKey, mbox.user.id, 1); err != nil { 292 | d.b.extStore.Delete([]string{extBodyKey}) 293 | return wrapErr(err, "Body (addExtKey)") 294 | } 295 | 296 | // Note that we are extremely careful here with ordering to 297 | // decrease change of deadlocks as a result of transaction 298 | // serialization. 299 | 300 | // --- operations that involve mboxes table --- 301 | msgId, err := mbox.incrementMsgCounters(d.tx) 302 | if err != nil { 303 | d.b.extStore.Delete([]string{extBodyKey}) 304 | return wrapErr(err, "Body (incrementMsgCounters)") 305 | } 306 | 307 | // --- operations that involve msgs table --- 308 | persistRecent := 0 309 | if mbox.parent.mngr.NewMessage(mbox.id, msgId) { 310 | persistRecent = 1 311 | } 312 | 313 | _, err = d.tx.Stmt(d.b.addMsg).Exec( 314 | mbox.id, msgId, date.Unix(), 315 | length, 316 | bodyStruct, cachedHeader, extBodyKey, 317 | 0, d.b.Opts.CompressAlgo, persistRecent, 318 | ) 319 | if err != nil { 320 | d.b.extStore.Delete([]string{extBodyKey}) 321 | return wrapErr(err, "Body (addMsg)") 322 | } 323 | // --- end of operations that involve msgs table --- 324 | 325 | // --- operations that involve flags table --- 326 | flags := d.flagOverrides[mbox.user.username] 327 | if len(flags) != 0 { 328 | 329 | params := mbox.makeFlagsAddStmtArgs(flags, msgId, msgId) 330 | if _, err := d.tx.Stmt(flagsStmt).Exec(params...); err != nil { 331 | d.b.extStore.Delete([]string{extBodyKey}) 332 | return wrapErr(err, "Body (flagsStmt)") 333 | } 334 | } 335 | // --- end operations that involve flags table --- 336 | 337 | return nil 338 | } 339 | 340 | func (d *Delivery) Abort() error { 341 | if d.tx != nil { 342 | if err := d.tx.Rollback(); err != nil { 343 | return err 344 | } 345 | } 346 | if d.extKey != "" { 347 | if err := d.b.extStore.Delete([]string{d.extKey}); err != nil { 348 | return err 349 | } 350 | } 351 | 352 | d.clean() 353 | return nil 354 | } 355 | 356 | // Commit finishes the delivery. 357 | // 358 | // If this function returns no error - the message is successfully added to the mailbox 359 | // of *all* recipients. 360 | // 361 | // After Commit or Abort is called, Delivery object can be reused as if it was 362 | // just created. 363 | func (d *Delivery) Commit() error { 364 | if d.tx != nil { 365 | if err := d.tx.Commit(); err != nil { 366 | return err 367 | } 368 | } 369 | 370 | d.clean() 371 | return nil 372 | } 373 | 374 | func (b *Backend) processParsedBody(headerInput []byte, header textproto.Header, bodyLiteral io.Reader, bodyLen int64) (bodyStruct, cachedHeader []byte, extBodyKey string, err error) { 375 | extBodyKey, err = randomKey() 376 | if err != nil { 377 | return nil, nil, "", err 378 | } 379 | 380 | objSize := int64(len(headerInput)) + bodyLen 381 | if b.Opts.CompressAlgo != "" { 382 | objSize = -1 383 | } 384 | 385 | extWriter, err := b.extStore.Create(extBodyKey, objSize) 386 | if err != nil { 387 | return nil, nil, "", err 388 | } 389 | defer extWriter.Close() 390 | 391 | compressW, err := b.compressAlgo.WrapCompress(extWriter, b.Opts.CompressAlgoParams) 392 | if err != nil { 393 | return nil, nil, "", err 394 | } 395 | defer compressW.Close() 396 | 397 | if _, err := compressW.Write(headerInput); err != nil { 398 | b.extStore.Delete([]string{extBodyKey}) 399 | return nil, nil, "", err 400 | } 401 | 402 | bufferedBody := bufio.NewReader(io.TeeReader(bodyLiteral, compressW)) 403 | bodyStruct, cachedHeader, err = extractCachedData(header, bufferedBody) 404 | if err != nil { 405 | b.extStore.Delete([]string{extBodyKey}) 406 | return nil, nil, "", err 407 | } 408 | 409 | // Consume all remaining body so io.TeeReader used with external store will 410 | // copy everything to extWriter. 411 | _, err = io.Copy(ioutil.Discard, bufferedBody) 412 | if err != nil { 413 | b.extStore.Delete([]string{extBodyKey}) 414 | return nil, nil, "", err 415 | } 416 | 417 | if err := extWriter.Sync(); err != nil { 418 | return nil, nil, "", err 419 | } 420 | 421 | return 422 | } 423 | -------------------------------------------------------------------------------- /delivery_test.go: -------------------------------------------------------------------------------- 1 | package imapsql 2 | 3 | import ( 4 | "bufio" 5 | "io/ioutil" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/emersion/go-imap" 10 | "github.com/emersion/go-imap/backend" 11 | "github.com/emersion/go-message/textproto" 12 | "gotest.tools/assert" 13 | is "gotest.tools/assert/cmp" 14 | ) 15 | 16 | var testMsgFetchItems = []imap.FetchItem{imap.FetchEnvelope, imap.FetchFlags, imap.FetchBodyStructure, imap.FetchRFC822Size /*"BODY.PEEK[]",*/, "BODY.PEEK[HEADER]", "BODY.PEEK[TEXT]"} 17 | 18 | func checkTestMsg(t *testing.T, msg *imap.Message) { 19 | t.Helper() 20 | 21 | hello := "Hello!" 22 | 23 | for _, item := range msg.Items { 24 | switch item { 25 | case imap.FetchEnvelope: 26 | assert.DeepEqual(t, msg.Envelope, &imap.Envelope{ 27 | Subject: hello, 28 | From: []*imap.Address{ 29 | { 30 | MailboxName: "foxcpp", 31 | HostName: "foxcpp.dev", 32 | }, 33 | }, 34 | }) 35 | case imap.FetchFlags: 36 | assert.DeepEqual(t, msg.Flags, []string{imap.RecentFlag}) 37 | case imap.FetchBodyStructure: 38 | assert.Equal(t, msg.BodyStructure.MIMEType, "text") 39 | assert.Equal(t, msg.BodyStructure.MIMESubType, "plain") 40 | case imap.FetchRFC822Size: 41 | assert.Equal(t, msg.Size, len(testMsg)) 42 | } 43 | } 44 | 45 | for key, literal := range msg.Body { 46 | blob, err := ioutil.ReadAll(literal) 47 | assert.NilError(t, err, "ReadAll literal") 48 | switch fetchItem := key.FetchItem(); fetchItem { 49 | case "BODY.PEEK[]": 50 | assert.DeepEqual(t, string(blob), testMsg) 51 | case "BODY.PEEK[HEADER]": 52 | assert.DeepEqual(t, string(blob), testMsgHeader) 53 | case "BODY.PEEK[TEXT]": 54 | assert.DeepEqual(t, string(blob), testMsgBody) 55 | default: 56 | t.Log("Unknown part:", fetchItem) 57 | } 58 | } 59 | } 60 | 61 | type noopConn struct{} 62 | 63 | func (n *noopConn) SendUpdate(_ backend.Update) error { 64 | return nil 65 | } 66 | 67 | func TestDelivery(t *testing.T) { 68 | b := initTestBackend().(*Backend) 69 | defer cleanBackend(b) 70 | assert.NilError(t, b.CreateUser(t.Name()+"-1"), "CreateUser 1") 71 | assert.NilError(t, b.CreateUser(t.Name()+"-2"), "CreateUser 2") 72 | 73 | delivery := b.NewDelivery() 74 | 75 | assert.NilError(t, delivery.AddRcpt(t.Name()+"-1", textproto.Header{}), "AddRcpt 1") 76 | assert.NilError(t, delivery.AddRcpt(t.Name()+"-2", textproto.Header{}), "AddRcpt 2") 77 | 78 | assert.NilError(t, delivery.BodyRaw(strings.NewReader(testMsg)), "BodyRaw") 79 | assert.NilError(t, delivery.Commit(), "Commit") 80 | 81 | u1, err := b.GetUser(t.Name() + "-1") 82 | assert.NilError(t, err, "GetUser 1") 83 | u2, err := b.GetUser(t.Name() + "-2") 84 | assert.NilError(t, err, "GetUser 2") 85 | 86 | _, mbox1, err := u1.GetMailbox("INBOX", true, &noopConn{}) 87 | assert.NilError(t, err, "GetMailbox 1 INBOX") 88 | defer mbox1.Close() 89 | _, mbox2, err := u2.GetMailbox("INBOX", true, &noopConn{}) 90 | assert.NilError(t, err, "GetMailbox 2 INBOX") 91 | defer mbox2.Close() 92 | 93 | seq, _ := imap.ParseSeqSet("1:*") 94 | ch := make(chan *imap.Message, 10) 95 | 96 | assert.NilError(t, mbox1.ListMessages(false, seq, testMsgFetchItems, ch), "ListMessages") 97 | assert.Assert(t, is.Len(ch, 1)) 98 | msg := <-ch 99 | checkTestMsg(t, msg) 100 | 101 | hasRecent := false 102 | for _, flag := range msg.Flags { 103 | if flag == imap.RecentFlag { 104 | hasRecent = true 105 | } 106 | } 107 | assert.Assert(t, hasRecent) 108 | 109 | ch = make(chan *imap.Message, 10) 110 | assert.NilError(t, mbox2.ListMessages(false, seq, testMsgFetchItems, ch), "ListMessages") 111 | assert.Assert(t, is.Len(ch, 1)) 112 | msg = <-ch 113 | checkTestMsg(t, msg) 114 | 115 | hasRecent = false 116 | for _, flag := range msg.Flags { 117 | if flag == imap.RecentFlag { 118 | hasRecent = true 119 | } 120 | } 121 | assert.Assert(t, hasRecent) 122 | } 123 | 124 | func TestDelivery_Abort(t *testing.T) { 125 | b := initTestBackend().(*Backend) 126 | defer cleanBackend(b) 127 | assert.NilError(t, b.CreateUser(t.Name()), "CreateUser") 128 | 129 | delivery := b.NewDelivery() 130 | assert.NilError(t, delivery.AddRcpt(t.Name(), textproto.Header{}), "AddRcpt") 131 | assert.NilError(t, delivery.BodyRaw(strings.NewReader(testMsg)), "BodyRaw") 132 | assert.NilError(t, delivery.Abort(), "Abort") 133 | 134 | u, err := b.GetUser(t.Name()) 135 | assert.NilError(t, err, "GetUser") 136 | status, mbox, err := u.GetMailbox("INBOX", true, &noopConn{}) 137 | assert.NilError(t, err, "GetMailbox") 138 | defer mbox.Close() 139 | assert.Equal(t, status.Messages, uint32(0)) 140 | } 141 | 142 | func TestDelivery_AddRcpt_NonExistent(t *testing.T) { 143 | b := initTestBackend().(*Backend) 144 | defer cleanBackend(b) 145 | assert.NilError(t, b.CreateUser(t.Name()), "CreateUser") 146 | 147 | delivery := b.NewDelivery() 148 | assert.NilError(t, delivery.AddRcpt(t.Name(), textproto.Header{})) 149 | 150 | err := delivery.AddRcpt("NON-EXISTENT", textproto.Header{}) 151 | assert.Assert(t, err != nil, "AddRcpt NON-EXISTENT INBOX") 152 | 153 | // Then, however, delivery should continue as if nothing happened. 154 | assert.NilError(t, delivery.BodyRaw(strings.NewReader(testMsg)), "BodyRaw") 155 | assert.NilError(t, delivery.Commit(), "Commit") 156 | 157 | // Check whether the message is delivered. 158 | u, err := b.GetUser(t.Name()) 159 | assert.NilError(t, err, "GetUser 1") 160 | _, mbox, err := u.GetMailbox("INBOX", true, &noopConn{}) 161 | assert.NilError(t, err, "GetMailbox INBOX") 162 | defer mbox.Close() 163 | 164 | seq, _ := imap.ParseSeqSet("*") 165 | ch := make(chan *imap.Message, 10) 166 | 167 | assert.NilError(t, mbox.ListMessages(false, seq, testMsgFetchItems, ch), "ListMessages") 168 | assert.Assert(t, is.Len(ch, 1)) 169 | msg := <-ch 170 | checkTestMsg(t, msg) 171 | 172 | // Below is subtest that verifys whether the the entities created later with non-existent names 173 | // are not suddenly populated with our message. 174 | 175 | t.Run("NON-EXISTENT user created empty", func(t *testing.T) { 176 | assert.NilError(t, b.CreateUser("NON-EXISTENT"), "CreateUser NON-EXISTENT") 177 | u, err := b.GetUser("NON-EXISTENT") 178 | assert.NilError(t, err, "GetUser NON-EXISTENT") 179 | status, mbox, err := u.GetMailbox("INBOX", true, &noopConn{}) 180 | assert.NilError(t, err, "GetMailbox INBOX") 181 | defer mbox.Close() 182 | 183 | assert.Equal(t, status.Messages, uint32(0), "INBOX of NON-EXISTENT user is non-empty") 184 | }) 185 | } 186 | 187 | func TestDelivery_Mailbox(t *testing.T) { 188 | test := func(t *testing.T, create bool) { 189 | b := initTestBackend().(*Backend) 190 | defer cleanBackend(b) 191 | assert.NilError(t, b.CreateUser(t.Name()), "CreateUser") 192 | u, err := b.GetUser(t.Name()) 193 | assert.NilError(t, err, "GetUser") 194 | if create { 195 | assert.NilError(t, u.CreateMailbox("Box")) 196 | } 197 | 198 | delivery := b.NewDelivery() 199 | 200 | assert.NilError(t, delivery.AddRcpt(t.Name(), textproto.Header{}), "AddRcpt") 201 | 202 | assert.NilError(t, delivery.Mailbox("Box")) 203 | assert.NilError(t, delivery.BodyRaw(strings.NewReader(testMsg)), "BodyRaw") 204 | assert.NilError(t, delivery.Commit(), "Commit") 205 | 206 | _, mbox, err := u.GetMailbox("Box", true, &noopConn{}) 207 | assert.NilError(t, err, "GetMailbox Box") 208 | defer mbox.Close() 209 | 210 | seq, _ := imap.ParseSeqSet("*") 211 | ch := make(chan *imap.Message, 10) 212 | 213 | assert.NilError(t, mbox.ListMessages(false, seq, testMsgFetchItems, ch), "ListMessages") 214 | assert.Assert(t, is.Len(ch, 1)) 215 | msg := <-ch 216 | checkTestMsg(t, msg) 217 | } 218 | 219 | test(t, true) 220 | t.Run("nonexistent", func(t *testing.T) { 221 | test(t, false) 222 | }) 223 | } 224 | 225 | func TestDelivery_SpecialMailbox(t *testing.T) { 226 | test := func(t *testing.T, create bool, specialUse string) { 227 | b := initTestBackend().(*Backend) 228 | defer cleanBackend(b) 229 | assert.NilError(t, b.CreateUser(t.Name()), "CreateUser") 230 | u, err := b.GetUser(t.Name()) 231 | assert.NilError(t, err, "GetUser") 232 | if create { 233 | assert.NilError(t, u.(*User).CreateMailboxSpecial("Box", specialUse)) 234 | } 235 | 236 | delivery := b.NewDelivery() 237 | 238 | assert.NilError(t, delivery.AddRcpt(t.Name(), textproto.Header{}), "AddRcpt") 239 | 240 | assert.NilError(t, delivery.SpecialMailbox(specialUse, "Box")) 241 | assert.NilError(t, delivery.BodyRaw(strings.NewReader(testMsg)), "BodyRaw") 242 | assert.NilError(t, delivery.Commit(), "Commit") 243 | 244 | _, mbox, err := u.GetMailbox("Box", true, &noopConn{}) 245 | assert.NilError(t, err, "GetMailbox Box") 246 | defer mbox.Close() 247 | 248 | seq, _ := imap.ParseSeqSet("*") 249 | ch := make(chan *imap.Message, 10) 250 | 251 | assert.NilError(t, mbox.ListMessages(false, seq, testMsgFetchItems, ch), "ListMessages") 252 | assert.Assert(t, is.Len(ch, 1)) 253 | msg := <-ch 254 | checkTestMsg(t, msg) 255 | 256 | if create { 257 | info, err := u.ListMailboxes(false) 258 | assert.NilError(t, err, "ListMailboxes failed") 259 | 260 | for _, box := range info { 261 | if box.Name != mbox.Name() { 262 | continue 263 | } 264 | 265 | containsSpecial := false 266 | for _, attr := range box.Attributes { 267 | if attr == specialUse { 268 | containsSpecial = true 269 | } 270 | } 271 | assert.Assert(t, containsSpecial, "Missing SPECIAL-USE attr") 272 | } 273 | } 274 | } 275 | 276 | test(t, true, imap.JunkAttr) 277 | t.Run("nonexistent", func(t *testing.T) { 278 | test(t, false, imap.JunkAttr) 279 | }) 280 | } 281 | 282 | func TestDelivery_BodyParsed(t *testing.T) { 283 | b := initTestBackend().(*Backend) 284 | defer cleanBackend(b) 285 | assert.NilError(t, b.CreateUser(t.Name()), "CreateUser") 286 | 287 | delivery := b.NewDelivery() 288 | 289 | assert.NilError(t, delivery.AddRcpt(t.Name(), textproto.Header{}), "AddRcpt") 290 | 291 | buf := memoryBuffer{slice: []byte(testMsgBody)} 292 | hdr, _ := textproto.ReadHeader(bufio.NewReader(strings.NewReader(testMsgHeader))) 293 | assert.NilError(t, delivery.BodyParsed(hdr, len(testMsgBody), buf), "BodyParsed") 294 | assert.NilError(t, delivery.Commit(), "Commit") 295 | 296 | u, err := b.GetUser(t.Name()) 297 | assert.NilError(t, err, "GetUser") 298 | 299 | _, mbox, err := u.GetMailbox("INBOX", true, &noopConn{}) 300 | assert.NilError(t, err, "GetMailbox INBOX") 301 | defer mbox.Close() 302 | 303 | seq, _ := imap.ParseSeqSet("*") 304 | ch := make(chan *imap.Message, 10) 305 | 306 | assert.NilError(t, mbox.ListMessages(false, seq, testMsgFetchItems, ch), "ListMessages") 307 | assert.Assert(t, is.Len(ch, 1)) 308 | msg := <-ch 309 | checkTestMsg(t, msg) 310 | } 311 | 312 | func TestDelivery_UserHeader(t *testing.T) { 313 | b := initTestBackend().(*Backend) 314 | defer cleanBackend(b) 315 | assert.NilError(t, b.CreateUser(t.Name()+"-1"), "CreateUser 1") 316 | assert.NilError(t, b.CreateUser(t.Name()+"-2"), "CreateUser 2") 317 | 318 | delivery := b.NewDelivery() 319 | 320 | hdr1 := textproto.Header{} 321 | hdr1.Set("Test-Header", "1") 322 | assert.NilError(t, delivery.AddRcpt(t.Name()+"-1", hdr1), "AddRcpt 1") 323 | hdr2 := textproto.Header{} 324 | hdr2.Set("Test-Header", "2") 325 | assert.NilError(t, delivery.AddRcpt(t.Name()+"-2", hdr2), "AddRcpt 2") 326 | 327 | assert.NilError(t, delivery.BodyRaw(strings.NewReader(testMsg)), "BodyRaw") 328 | assert.NilError(t, delivery.Commit(), "Commit") 329 | 330 | u1, err := b.GetUser(t.Name() + "-1") 331 | assert.NilError(t, err, "GetUser 1") 332 | u2, err := b.GetUser(t.Name() + "-2") 333 | assert.NilError(t, err, "GetUser 2") 334 | 335 | _, mbox1, err := u1.GetMailbox("INBOX", true, &noopConn{}) 336 | assert.NilError(t, err, "GetMailbox 1 INBOX") 337 | defer mbox1.Close() 338 | _, mbox2, err := u2.GetMailbox("INBOX", true, &noopConn{}) 339 | assert.NilError(t, err, "GetMailbox 2 INBOX") 340 | defer mbox2.Close() 341 | 342 | seq, _ := imap.ParseSeqSet("*") 343 | ch := make(chan *imap.Message, 10) 344 | 345 | assert.NilError(t, mbox1.ListMessages(false, seq, []imap.FetchItem{"BODY.PEEK[HEADER]"}, ch), "ListMessages") 346 | assert.Assert(t, is.Len(ch, 1)) 347 | msg := <-ch 348 | for _, part := range msg.Body { 349 | hdr, err := textproto.ReadHeader(bufio.NewReader(part)) 350 | assert.NilError(t, err, "ReadHeader") 351 | assert.Check(t, is.Equal(hdr.Get("Test-Header"), "1"), "wrong user header stored") 352 | } 353 | 354 | ch = make(chan *imap.Message, 10) 355 | assert.NilError(t, mbox2.ListMessages(false, seq, []imap.FetchItem{"BODY.PEEK[HEADER]"}, ch), "ListMessages") 356 | assert.Assert(t, is.Len(ch, 1)) 357 | msg = <-ch 358 | for _, part := range msg.Body { 359 | hdr, err := textproto.ReadHeader(bufio.NewReader(part)) 360 | assert.NilError(t, err, "ReadHeader") 361 | assert.Check(t, is.Equal(hdr.Get("Test-Header"), "2"), "wrong user header stored") 362 | } 363 | } 364 | -------------------------------------------------------------------------------- /envelope.go: -------------------------------------------------------------------------------- 1 | package imapsql 2 | 3 | import ( 4 | "errors" 5 | "net/mail" 6 | "strings" 7 | "time" 8 | 9 | "github.com/emersion/go-imap" 10 | ) 11 | 12 | type rawEnvelope struct { 13 | Date time.Time 14 | Subject string 15 | From string 16 | Sender string 17 | ReplyTo string 18 | To string 19 | CC string 20 | BCC string 21 | InReplyTo string 22 | MessageID string 23 | } 24 | 25 | func envelopeFromHeader(hdr map[string][]string) rawEnvelope { 26 | enve := rawEnvelope{} 27 | date := hdr["Date"] 28 | if date != nil { 29 | t, err := parseMessageDateTime(date[0]) 30 | if err == nil { 31 | enve.Date = t 32 | } 33 | } 34 | 35 | addrFields := [...]string{"From", "Sender", "Reply-To", "To", "Cc", "Bcc", "In-Reply-To"} 36 | for i, fieldVar := range [...]*string{ 37 | &enve.From, &enve.Sender, &enve.ReplyTo, 38 | &enve.To, &enve.CC, &enve.BCC, &enve.InReplyTo, 39 | } { 40 | val := hdr[addrFields[i]] 41 | if val == nil { 42 | continue 43 | } 44 | 45 | *fieldVar = strings.Join(val, ", ") 46 | } 47 | 48 | if enve.Sender == "" { 49 | enve.Sender = enve.From 50 | } 51 | if enve.ReplyTo == "" { 52 | enve.ReplyTo = enve.From 53 | } 54 | 55 | if val := hdr["Subject"]; val != nil { 56 | enve.Subject = val[0] 57 | } 58 | if val := hdr["Message-Id"]; val != nil { 59 | enve.MessageID = val[0] 60 | } 61 | 62 | return enve 63 | } 64 | 65 | func toImapAddr(list []*mail.Address) ([]*imap.Address, error) { 66 | res := make([]*imap.Address, 0, len(list)) 67 | for _, mailAddr := range list { 68 | imapAddr := imap.Address{} 69 | imapAddr.PersonalName = mailAddr.Name 70 | addrParts := strings.Split(mailAddr.Address, "@") 71 | if len(addrParts) != 2 { 72 | return res, errors.New("imap: malformed address") 73 | } 74 | 75 | imapAddr.MailboxName = addrParts[0] 76 | imapAddr.HostName = addrParts[1] 77 | res = append(res, &imapAddr) 78 | } 79 | return res, nil 80 | } 81 | 82 | func (enve *rawEnvelope) toIMAP() *imap.Envelope { 83 | res := new(imap.Envelope) 84 | res.Date = enve.Date 85 | res.Subject = enve.Subject 86 | from, _ := mail.ParseAddressList(enve.From) 87 | res.From, _ = toImapAddr(from) 88 | // I really wonder how we can have multiple senders in a message header, 89 | // but imap.Envelope says we can. 90 | sender, _ := mail.ParseAddressList(enve.Sender) 91 | res.Sender, _ = toImapAddr(sender) 92 | replyTo, _ := mail.ParseAddressList(enve.ReplyTo) 93 | res.ReplyTo, _ = toImapAddr(replyTo) 94 | to, _ := mail.ParseAddressList(enve.To) 95 | res.To, _ = toImapAddr(to) 96 | cc, _ := mail.ParseAddressList(enve.CC) 97 | res.Cc, _ = toImapAddr(cc) 98 | bcc, _ := mail.ParseAddressList(enve.BCC) 99 | res.Bcc, _ = toImapAddr(bcc) 100 | res.InReplyTo = enve.InReplyTo 101 | res.MessageId = enve.MessageID 102 | return res 103 | } 104 | -------------------------------------------------------------------------------- /errors.go: -------------------------------------------------------------------------------- 1 | //+build cgo,!nosqlite3 2 | 3 | package imapsql 4 | 5 | import ( 6 | "fmt" 7 | 8 | "github.com/lib/pq" 9 | "github.com/mattn/go-sqlite3" 10 | ) 11 | 12 | func isSerializationErr(err error) bool { 13 | if sqliteErr, ok := err.(sqlite3.Error); ok { 14 | return sqliteErr.Code == sqlite3.ErrBusy || 15 | sqliteErr.Code == sqlite3.ErrLocked 16 | } 17 | if pqErr, ok := err.(*pq.Error); ok { 18 | return pqErr.Code.Class() == "40" 19 | } 20 | 21 | return false 22 | } 23 | 24 | func wrapErr(err error, desc string) error { 25 | if err == nil { 26 | return nil 27 | } 28 | return fmt.Errorf(desc+": %w", err) 29 | } 30 | 31 | func wrapErrf(err error, format string, args ...interface{}) error { 32 | if err == nil { 33 | return nil 34 | } 35 | if isSerializationErr(err) { 36 | return SerializationError{Err: err} 37 | } 38 | 39 | args = append(args, err) 40 | return fmt.Errorf(format+": %w", args...) 41 | } 42 | -------------------------------------------------------------------------------- /errors_noncgo.go: -------------------------------------------------------------------------------- 1 | //+build !cgo nosqlite3 2 | 3 | package imapsql 4 | 5 | import ( 6 | "fmt" 7 | 8 | "github.com/lib/pq" 9 | ) 10 | 11 | func isSerializationErr(err error) bool { 12 | if pqErr, ok := err.(*pq.Error); ok { 13 | return pqErr.Code.Class() == "40" 14 | } 15 | 16 | return false 17 | } 18 | 19 | func wrapErr(err error, desc string) error { 20 | if err == nil { 21 | return nil 22 | } 23 | return fmt.Errorf(desc+": %w", err) 24 | } 25 | 26 | func wrapErrf(err error, format string, args ...interface{}) error { 27 | if err == nil { 28 | return nil 29 | } 30 | if isSerializationErr(err) { 31 | return SerializationError{Err: err} 32 | } 33 | 34 | args = append(args, err) 35 | return fmt.Errorf(format+": %w", args...) 36 | } 37 | -------------------------------------------------------------------------------- /external_store.go: -------------------------------------------------------------------------------- 1 | package imapsql 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/hex" 6 | "fmt" 7 | "io" 8 | ) 9 | 10 | type ExtStoreObj interface { 11 | Sync() error 12 | io.Reader 13 | io.Writer 14 | io.Closer 15 | } 16 | 17 | type ExternalError struct { 18 | // true if error was caused by an attempt to access non-existent key. 19 | NonExistent bool 20 | 21 | Key string 22 | Err error 23 | } 24 | 25 | // Unwrap implements Unwrap() for Go 1.13 'errors'. 26 | func (err ExternalError) Unwrap() error { 27 | return err.Err 28 | } 29 | 30 | // Cause implements Cause() for pkg/errors. 31 | func (err ExternalError) Cause() error { 32 | return err.Err 33 | } 34 | 35 | func (err ExternalError) Error() string { 36 | if err.NonExistent { 37 | return fmt.Sprintf("external: non-existent key %s", err.Key) 38 | } 39 | return fmt.Sprintf("external: %v", err.Err) 40 | } 41 | 42 | /* 43 | ExternalStore is an interface used by go-imap-sql to store message bodies 44 | outside of main database. 45 | */ 46 | type ExternalStore interface { 47 | Create(key string, objectSize int64) (ExtStoreObj, error) 48 | 49 | // Open returns the ExtStoreObj that reads the message body specified by 50 | // passed key. 51 | // 52 | // If no such message exists - ExternalError with NonExistent = true is 53 | // returned. 54 | Open(key string) (ExtStoreObj, error) 55 | 56 | // Delete removes a set of keys from store. Non-existent keys are ignored. 57 | Delete(keys []string) error 58 | } 59 | 60 | func randomKey() (string, error) { 61 | b := make([]byte, 16) 62 | if _, err := rand.Read(b); err != nil { 63 | return "", err 64 | } 65 | return hex.EncodeToString(b), nil 66 | } 67 | -------------------------------------------------------------------------------- /fetch.go: -------------------------------------------------------------------------------- 1 | package imapsql 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "database/sql" 7 | "encoding/json" 8 | "fmt" 9 | "io" 10 | nettextproto "net/textproto" 11 | "strings" 12 | "time" 13 | 14 | "github.com/emersion/go-imap" 15 | "github.com/emersion/go-imap/backend/backendutil" 16 | "github.com/emersion/go-message/textproto" 17 | ) 18 | 19 | func (m *Mailbox) ListMessages(uid bool, seqset *imap.SeqSet, items []imap.FetchItem, ch chan<- *imap.Message) error { 20 | defer close(ch) 21 | var err error 22 | 23 | setSeen := !m.readOnly && shouldSetSeen(items) 24 | var addSeenStmt *sql.Stmt 25 | if setSeen { 26 | addSeenStmt, err = m.parent.getFlagsAddStmt(1) 27 | if err != nil { 28 | m.parent.logMboxErr(m, err, "ListMessages (getFlagsAddStmt)", uid, seqset, items) 29 | return err 30 | } 31 | 32 | // Duplicate entries (if any) shouldn't cause problems. 33 | items = append(items, imap.FetchFlags) 34 | } 35 | 36 | stmt, err := m.parent.getFetchStmt(items) 37 | if err != nil { 38 | m.parent.logMboxErr(m, err, "ListMessages (getFetchStmt)", uid, seqset, items) 39 | return err 40 | } 41 | 42 | // don't close statement, it is owned by cache 43 | tx, err := m.parent.db.BeginLevel(sql.LevelReadCommitted, !setSeen) 44 | if err != nil { 45 | m.parent.logMboxErr(m, err, "ListMessages (tx start)", uid, seqset, items) 46 | return err 47 | } 48 | defer tx.Rollback() 49 | 50 | seqset, err = m.handle.ResolveSeq(uid, seqset) 51 | if err != nil { 52 | if uid { 53 | return nil 54 | } 55 | return err 56 | } 57 | 58 | m.parent.Opts.Log.Debugln("resolved", uid, seqset, "to", seqset) 59 | 60 | for _, seq := range seqset.Set { 61 | if setSeen { 62 | params := m.makeFlagsAddStmtArgs([]string{imap.SeenFlag}, seq.Start, seq.Stop) 63 | if _, err := tx.Stmt(addSeenStmt).Exec(params...); err != nil { 64 | m.parent.logMboxErr(m, err, "ListMessages (add seen)", uid, seqset, items) 65 | return err 66 | } 67 | 68 | _, err = tx.Stmt(m.parent.setSeenFlagUid).Exec(1, m.id, seq.Start, seq.Stop) 69 | if err != nil { 70 | m.parent.logMboxErr(m, err, "ListMessages (setSeenFlag)", uid, seqset, items) 71 | return err 72 | } 73 | } 74 | 75 | rows, err := tx.Stmt(stmt).Query(m.id, seq.Start, seq.Stop) 76 | if err != nil { 77 | m.parent.logMboxErr(m, err, "ListMessages", uid, seqset, items) 78 | return err 79 | } 80 | if err := m.scanMessages(rows, items, ch); err != nil { 81 | m.parent.logMboxErr(m, err, "ListMessages (scan)", uid, seqset, items) 82 | return err 83 | } 84 | } 85 | 86 | return nil 87 | } 88 | 89 | type scanData struct { 90 | cachedHeaderBlob, bodyStructureBlob []byte 91 | 92 | seqNum, msgId uint32 93 | dateUnix int64 94 | bodyLen uint32 95 | flagStr string 96 | extBodyKey string 97 | compressAlgo string 98 | 99 | bodyStructure *imap.BodyStructure 100 | cachedHeader map[string][]string 101 | parsedHeader *textproto.Header 102 | } 103 | 104 | func makeScanArgs(data *scanData, rows *sql.Rows) ([]interface{}, error) { 105 | cols, err := rows.Columns() 106 | if err != nil { 107 | return nil, err 108 | } 109 | 110 | scanOrder := make([]interface{}, 0, len(cols)) 111 | for _, col := range cols { 112 | // PostgreSQL case-folds column names to lower-case. 113 | switch col { 114 | case "date": 115 | scanOrder = append(scanOrder, &data.dateUnix) 116 | case "bodyLen", "bodylen": 117 | scanOrder = append(scanOrder, &data.bodyLen) 118 | case "msgId", "msgid": 119 | scanOrder = append(scanOrder, &data.msgId) 120 | case "cachedHeader", "cachedheader": 121 | scanOrder = append(scanOrder, &data.cachedHeaderBlob) 122 | case "bodyStructure", "bodystructure": 123 | scanOrder = append(scanOrder, &data.bodyStructureBlob) 124 | case "compressAlgo", "compressalgo": 125 | scanOrder = append(scanOrder, &data.compressAlgo) 126 | case "extBodyKey", "extbodykey": 127 | scanOrder = append(scanOrder, &data.extBodyKey) 128 | case "flags": 129 | scanOrder = append(scanOrder, &data.flagStr) 130 | default: 131 | panic("unknown column: " + col) 132 | } 133 | } 134 | 135 | return scanOrder, nil 136 | } 137 | 138 | func (m *Mailbox) scanMessages(rows *sql.Rows, items []imap.FetchItem, ch chan<- *imap.Message) error { 139 | defer rows.Close() 140 | data := scanData{} 141 | 142 | scanArgs, err := makeScanArgs(&data, rows) 143 | if err != nil { 144 | return err 145 | } 146 | 147 | messageLoop: 148 | for rows.Next() { 149 | if err := rows.Scan(scanArgs...); err != nil { 150 | return err 151 | } 152 | 153 | data.parsedHeader = nil 154 | data.cachedHeader = nil 155 | data.bodyStructure = nil 156 | 157 | if data.cachedHeaderBlob != nil { 158 | if err := json.Unmarshal(data.cachedHeaderBlob, &data.cachedHeader); err != nil { 159 | return err 160 | } 161 | } 162 | if data.bodyStructureBlob != nil { 163 | if err := json.Unmarshal(data.bodyStructureBlob, &data.bodyStructure); err != nil { 164 | return err 165 | } 166 | } 167 | 168 | seqNum, ok := m.handle.UidAsSeq(data.msgId) 169 | if !ok { 170 | continue 171 | } 172 | 173 | msg := imap.NewMessage(seqNum, items) 174 | for _, item := range items { 175 | switch item { 176 | case imap.FetchInternalDate: 177 | msg.InternalDate = time.Unix(data.dateUnix, 0) 178 | case imap.FetchRFC822Size: 179 | msg.Size = data.bodyLen 180 | case imap.FetchUid: 181 | msg.Uid = data.msgId 182 | case imap.FetchEnvelope: 183 | raw := envelopeFromHeader(data.cachedHeader) 184 | msg.Envelope = raw.toIMAP() 185 | case imap.FetchBody: 186 | msg.BodyStructure = stripExtBodyStruct(data.bodyStructure) 187 | case imap.FetchBodyStructure: 188 | msg.BodyStructure = data.bodyStructure 189 | case imap.FetchFlags: 190 | if data.flagStr != "" { 191 | msg.Flags = strings.Split(data.flagStr, flagsSep) 192 | } else { 193 | msg.Flags = []string{} 194 | } 195 | if m.handle.IsRecent(data.msgId) { 196 | msg.Flags = append(msg.Flags, imap.RecentFlag) 197 | } 198 | default: 199 | if err := m.extractBodyPart(item, &data, msg); err != nil { 200 | m.parent.logMboxErr(m, err, "failed to read body, skipping", data.seqNum, data.extBodyKey) 201 | continue messageLoop 202 | } 203 | } 204 | } 205 | 206 | m.parent.Opts.Log.Debugf("scanMessages: scanned msgId=%v (seq %v) %v", data.msgId, seqNum, items) 207 | 208 | ch <- msg 209 | } 210 | if err := rows.Err(); err != nil { 211 | return err 212 | } 213 | 214 | return nil 215 | } 216 | 217 | func (m *Mailbox) extractBodyPart(item imap.FetchItem, data *scanData, msg *imap.Message) error { 218 | sect, part, err := getNeededPart(item) 219 | if err != nil { 220 | return err 221 | } 222 | 223 | switch part { 224 | case needCachedHeader: 225 | var err error 226 | msg.Body[sect], err = headerSubsetFromCached(sect, data.cachedHeader) 227 | if err != nil { 228 | return err 229 | } 230 | case needHeader, needFullBody: 231 | // We don't need to parse header once more if we already did, so we just skip it if we open body 232 | // multiple times. 233 | bufferedBody, err := m.openBody(data.parsedHeader == nil, data.compressAlgo, data.extBodyKey) 234 | if err != nil { 235 | return err 236 | } 237 | defer bufferedBody.Close() 238 | 239 | if data.parsedHeader == nil { 240 | hdr, err := textproto.ReadHeader(bufferedBody.Reader) 241 | if err != nil { 242 | return err 243 | } 244 | data.parsedHeader = &hdr 245 | } 246 | 247 | msg.Body[sect], err = backendutil.FetchBodySection(*data.parsedHeader, bufferedBody.Reader, sect) 248 | if err != nil { 249 | m.parent.logMboxErr(m, err, "failed to fetch body section", data.seqNum, sect) 250 | msg.Body[sect] = bytes.NewReader(nil) 251 | } 252 | } 253 | 254 | return nil 255 | } 256 | 257 | type BufferedReadCloser struct { 258 | *bufio.Reader 259 | io.Closer 260 | } 261 | 262 | type nopCloser struct{ io.Writer } 263 | 264 | func (n nopCloser) Close() error { 265 | return nil 266 | } 267 | 268 | func (m *Mailbox) openBody(needHeader bool, compressAlgoColumn, extBodyKey string) (BufferedReadCloser, error) { 269 | rdr, err := m.parent.extStore.Open(extBodyKey) 270 | if err != nil { 271 | return BufferedReadCloser{}, wrapErr(err, "openBody") 272 | } 273 | 274 | // compressAlgoColumn is in 'name params' format. 275 | compressAlgoInfo := strings.Split(compressAlgoColumn, " ") 276 | algoImpl, ok := compressionAlgos[compressAlgoInfo[0]] 277 | if !ok { 278 | return BufferedReadCloser{}, fmt.Errorf("openBody: unknown compression algorithm used for body: %s", compressAlgoInfo[0]) 279 | } 280 | rdrDecomp, err := algoImpl.WrapDecompress(rdr) 281 | if err != nil { 282 | return BufferedReadCloser{}, wrapErr(err, "openBody") 283 | } 284 | 285 | bufR := bufio.NewReader(rdrDecomp) 286 | if !needHeader { 287 | for { 288 | // Skip header if it is not needed. 289 | line, err := bufR.ReadSlice('\n') 290 | if err != nil { 291 | return BufferedReadCloser{}, wrapErr(err, "openBody") 292 | } 293 | // If line is empty (message uses LF delim) or contains only CR (messages uses CRLF delim) 294 | if len(line) == 0 || (len(line) == 1 || line[0] == '\r') { 295 | break 296 | } 297 | } 298 | } 299 | 300 | return BufferedReadCloser{Reader: bufR, Closer: rdr}, nil 301 | } 302 | 303 | func headerSubsetFromCached(sect *imap.BodySectionName, cachedHeader map[string][]string) (imap.Literal, error) { 304 | hdr := textproto.Header{} 305 | for i := len(sect.Fields) - 1; i >= 0; i-- { 306 | field := sect.Fields[i] 307 | 308 | // If field requested multiple times - return only once. 309 | if hdr.Has(field) { 310 | continue 311 | } 312 | 313 | value := cachedHeader[nettextproto.CanonicalMIMEHeaderKey(field)] 314 | for i := len(value) - 1; i >= 0; i-- { 315 | subval := value[i] 316 | hdr.Add(field, subval) 317 | } 318 | } 319 | 320 | buf := new(bytes.Buffer) 321 | if err := textproto.WriteHeader(buf, hdr); err != nil { 322 | return nil, err 323 | } 324 | 325 | var l imap.Literal = buf 326 | if sect.Partial != nil { 327 | l = bytes.NewReader(sect.ExtractPartial(buf.Bytes())) 328 | } 329 | 330 | return l, nil 331 | } 332 | 333 | func stripExtBodyStruct(extended *imap.BodyStructure) *imap.BodyStructure { 334 | stripped := *extended 335 | stripped.Extended = false 336 | stripped.Disposition = "" 337 | stripped.DispositionParams = nil 338 | stripped.Language = nil 339 | stripped.Location = nil 340 | stripped.MD5 = "" 341 | 342 | for i := range stripped.Parts { 343 | stripped.Parts[i] = stripExtBodyStruct(stripped.Parts[i]) 344 | } 345 | return &stripped 346 | } 347 | 348 | func shouldSetSeen(items []imap.FetchItem) bool { 349 | for _, item := range items { 350 | switch item { 351 | case imap.FetchInternalDate, imap.FetchRFC822Size, imap.FetchUid, imap.FetchEnvelope, 352 | imap.FetchBody, imap.FetchBodyStructure, imap.FetchFlags: 353 | continue 354 | default: 355 | sect, err := imap.ParseBodySectionName(item) 356 | if err != nil { 357 | return false 358 | } 359 | if !sect.Peek { 360 | return true 361 | } 362 | } 363 | } 364 | return false 365 | } 366 | -------------------------------------------------------------------------------- /flags.go: -------------------------------------------------------------------------------- 1 | package imapsql 2 | 3 | import ( 4 | "database/sql" 5 | "strings" 6 | 7 | "github.com/emersion/go-imap" 8 | ) 9 | 10 | func (m *Mailbox) UpdateMessagesFlags(uid bool, seqset *imap.SeqSet, operation imap.FlagsOp, silent bool, flags []string) error { 11 | defer m.handle.Sync(uid) 12 | 13 | seenModified := false 14 | newFlagSet := make([]string, 0, len(flags)) 15 | for _, flag := range flags { 16 | if flag == imap.RecentFlag { 17 | continue 18 | } 19 | if flag == imap.SeenFlag { 20 | seenModified = true 21 | } 22 | newFlagSet = append(newFlagSet, flag) 23 | } 24 | flags = newFlagSet 25 | 26 | var err error 27 | var addQuery, remQuery *sql.Stmt 28 | switch operation { 29 | case imap.SetFlags, imap.AddFlags: 30 | if len(flags) != 0 { 31 | addQuery, err = m.parent.getFlagsAddStmt(len(flags)) 32 | } 33 | case imap.RemoveFlags: 34 | if len(flags) != 0 { 35 | remQuery, err = m.parent.getFlagsRemStmt(len(flags)) 36 | } 37 | } 38 | if err != nil { 39 | return wrapErr(err, "UpdateMessagesFlags") 40 | } 41 | 42 | tx, err := m.parent.db.BeginLevel(sql.LevelRepeatableRead, false) 43 | if err != nil { 44 | return wrapErr(err, "UpdateMessagesFlags") 45 | } 46 | defer tx.Rollback() // nolint:errcheck 47 | 48 | seqset, err = m.handle.ResolveSeq(uid, seqset) 49 | if err != nil { 50 | return err 51 | } 52 | 53 | for _, seq := range seqset.Set { 54 | switch operation { 55 | case imap.SetFlags: 56 | _, err = tx.Stmt(m.parent.massClearFlagsUid).Exec(m.id, seq.Start, seq.Stop) 57 | if err != nil { 58 | return err 59 | } 60 | fallthrough 61 | case imap.AddFlags: 62 | if seenModified { 63 | _, err = tx.Stmt(m.parent.setSeenFlagUid).Exec(1, m.id, seq.Start, seq.Stop) 64 | if err != nil { 65 | return err 66 | } 67 | } 68 | 69 | if len(flags) == 0 { 70 | continue 71 | } 72 | 73 | args := m.makeFlagsAddStmtArgs(flags, seq.Start, seq.Stop) 74 | if _, err := tx.Stmt(addQuery).Exec(args...); err != nil { 75 | return err 76 | } 77 | case imap.RemoveFlags: 78 | if seenModified { 79 | _, err = tx.Stmt(m.parent.setSeenFlagUid).Exec(0, m.id, seq.Start, seq.Stop) 80 | if err != nil { 81 | return err 82 | } 83 | } 84 | 85 | if len(flags) == 0 { 86 | continue 87 | } 88 | 89 | args := m.makeFlagsRemStmtArgs(flags, seq.Start, seq.Stop) 90 | if _, err := tx.Stmt(remQuery).Exec(args...); err != nil { 91 | return err 92 | } 93 | } 94 | } 95 | 96 | // We buffer updates before transaction commit so we 97 | // will not send them if tx.Commit fails. 98 | updatesBuffer, err := m.flagUpdates(tx, uid, seqset) 99 | if err != nil { 100 | return wrapErr(err, "UpdateMessagesFlags") 101 | } 102 | m.parent.Opts.Log.Debugln("UpdateMessageFlags: emitting", len(updatesBuffer), "flag updates") 103 | 104 | if err := tx.Commit(); err != nil { 105 | return wrapErr(err, "UpdateMessagesFlags") 106 | } 107 | 108 | for _, upd := range updatesBuffer { 109 | m.handle.FlagsChanged(upd.uid, upd.flags, silent) 110 | } 111 | return nil 112 | } 113 | 114 | type flagUpdate struct { 115 | uid uint32 116 | flags []string 117 | } 118 | 119 | func (m *Mailbox) flagUpdates(tx *sql.Tx, uid bool, seqset *imap.SeqSet) ([]flagUpdate, error) { 120 | var updatesBuffer []flagUpdate 121 | 122 | for _, seq := range seqset.Set { 123 | var err error 124 | var rows *sql.Rows 125 | 126 | rows, err = tx.Stmt(m.parent.msgFlagsUid).Query(m.id, seq.Start, seq.Stop) 127 | if err != nil { 128 | return nil, err 129 | } 130 | defer rows.Close() // It is fine. 131 | 132 | for rows.Next() { 133 | var msgId uint32 134 | var flagsJoined string 135 | 136 | if err := rows.Scan(&msgId, &flagsJoined); err != nil { 137 | return nil, err 138 | } 139 | 140 | updatesBuffer = append(updatesBuffer, flagUpdate{ 141 | uid: msgId, 142 | flags: strings.Split(flagsJoined, flagsSep), 143 | }) 144 | } 145 | if err := rows.Err(); err != nil { 146 | return nil, err 147 | } 148 | 149 | rows.Close() 150 | } 151 | 152 | return updatesBuffer, nil 153 | } 154 | -------------------------------------------------------------------------------- /fsstore.go: -------------------------------------------------------------------------------- 1 | package imapsql 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | ) 7 | 8 | // FSStore struct represents directory on FS used to store message bodies. 9 | // 10 | // Always use field names on initialization because new fields may be added 11 | // without a major version change. 12 | type FSStore struct { 13 | Root string 14 | } 15 | 16 | func (s *FSStore) Open(key string) (ExtStoreObj, error) { 17 | f, err := os.Open(filepath.Join(s.Root, key)) 18 | if err != nil { 19 | return nil, ExternalError{ 20 | Key: key, 21 | Err: err, 22 | NonExistent: os.IsNotExist(err), 23 | } 24 | } 25 | return f, nil 26 | } 27 | 28 | func (s *FSStore) Create(key string, blobSize int64) (ExtStoreObj, error) { 29 | f, err := os.Create(filepath.Join(s.Root, key)) 30 | if err != nil { 31 | return nil, ExternalError{ 32 | Key: key, 33 | Err: err, 34 | NonExistent: false, 35 | } 36 | } 37 | if blobSize != -1 { 38 | if err := f.Truncate(blobSize); err != nil { 39 | return nil, ExternalError{ 40 | Key: key, 41 | Err: err, 42 | } 43 | } 44 | } 45 | return f, nil 46 | } 47 | 48 | func (s *FSStore) Delete(keys []string) error { 49 | for _, key := range keys { 50 | if err := os.Remove(filepath.Join(s.Root, key)); err != nil { 51 | if os.IsNotExist(err) { 52 | continue 53 | } 54 | return ExternalError{ 55 | Key: key, 56 | Err: err, 57 | } 58 | } 59 | } 60 | return nil 61 | } 62 | -------------------------------------------------------------------------------- /fsstore_test.go: -------------------------------------------------------------------------------- 1 | package imapsql 2 | 3 | import ( 4 | "io/ioutil" 5 | "log" 6 | "math/rand" 7 | "os" 8 | "path/filepath" 9 | "testing" 10 | 11 | backendtests "github.com/foxcpp/go-imap-backend-tests" 12 | _ "github.com/go-sql-driver/mysql" 13 | _ "github.com/lib/pq" 14 | _ "github.com/mattn/go-sqlite3" 15 | ) 16 | 17 | var TestDB = os.Getenv("TEST_DB") 18 | var TestDSN = os.Getenv("TEST_DSN") 19 | 20 | func initTestBackend() backendtests.Backend { 21 | driver := TestDB 22 | dsn := TestDSN 23 | 24 | if TestDB == "" { 25 | driver = "sqlite3" 26 | dsn = ":memory:" 27 | } 28 | 29 | randSrc := rand.NewSource(0) 30 | prng := rand.New(randSrc) 31 | 32 | tempDir, err := ioutil.TempDir("", "go-imap-sql-tests-") 33 | if err != nil { 34 | panic(err) 35 | } 36 | 37 | // This is meant for DB debugging. 38 | if os.Getenv("PRESERVE_SQLITE3_DB") == "1" { 39 | log.Println("Using sqlite3 DB in temporary directory.") 40 | driver = "sqlite3" 41 | dsn = filepath.Join(tempDir, "test.db") 42 | } 43 | 44 | storeDir := filepath.Join(tempDir, "store") 45 | if err := os.MkdirAll(storeDir, os.ModeDir|os.ModePerm); err != nil { 46 | panic(err) 47 | } 48 | 49 | var log Logger 50 | if testing.Verbose() { 51 | log = globalLogger{} 52 | } else { 53 | log = DummyLogger{} 54 | } 55 | 56 | b, err := New(driver, dsn, &FSStore{Root: storeDir}, Opts{ 57 | PRNG: prng, 58 | Log: log, 59 | }) 60 | if err != nil { 61 | panic(err) 62 | } 63 | return b 64 | } 65 | 66 | func cleanBackend(bi backendtests.Backend) { 67 | b := bi.(*Backend) 68 | if os.Getenv("PRESERVE_DB") != "1" && os.Getenv("PRESERVE_SQLITE3_DB") != "1" { 69 | // Remove things manually in the right order so we will not hit 70 | // foreign key constraint when dropping tables. 71 | if _, err := b.DB.Exec(`DELETE FROM msgs`); err != nil { 72 | log.Println("DELETE FROM msgs", err) 73 | } 74 | if _, err := b.DB.Exec(`DELETE FROM extKeys`); err != nil { 75 | log.Println("DELETE FROM extKeys", err) 76 | } 77 | 78 | if _, err := b.DB.Exec(`DROP TABLE flags`); err != nil { 79 | log.Println("DROP TABLE flags", err) 80 | } 81 | if _, err := b.DB.Exec(`DROP TABLE msgs`); err != nil { 82 | log.Println("DROP TABLE msgs", err) 83 | } 84 | if _, err := b.DB.Exec(`DROP TABLE mboxes`); err != nil { 85 | log.Println("DROP TABLE mboxes", err) 86 | } 87 | if _, err := b.DB.Exec(`DROP TABLE users`); err != nil { 88 | log.Println("DROP TABLE users", err) 89 | } 90 | if _, err := b.DB.Exec(`DROP TABLE extKeys`); err != nil { 91 | log.Println("DROP TABLE extKeys", err) 92 | } 93 | 94 | if err := os.RemoveAll(b.extStore.(*FSStore).Root); err != nil { 95 | log.Println(err) 96 | } 97 | } 98 | b.Close() 99 | } 100 | 101 | func TestWithFSStore(t *testing.T) { 102 | backendtests.RunTests(t, initTestBackend, cleanBackend) 103 | } 104 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/foxcpp/go-imap-sql 2 | 3 | go 1.12 4 | 5 | require ( 6 | github.com/cpuguy83/go-md2man/v2 v2.0.3 // indirect 7 | github.com/emersion/go-imap v1.2.2-0.20220928192137-6fac715be9cf 8 | github.com/emersion/go-imap-sortthread v1.2.0 9 | github.com/emersion/go-message v0.18.0 10 | github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 // indirect 11 | github.com/foxcpp/go-imap-backend-tests v0.0.0-20220105184719-e80aa29a5e16 12 | github.com/foxcpp/go-imap-mess v0.0.0-20230108134257-b7ec3a649613 13 | github.com/foxcpp/go-imap-namespace v0.0.0-20200802091432-08496dd8e0ed 14 | github.com/frankban/quicktest v1.5.0 // indirect 15 | github.com/go-sql-driver/mysql v1.7.1 16 | github.com/google/go-cmp v0.5.5 // indirect 17 | github.com/klauspost/compress v1.17.4 18 | github.com/lib/pq v1.10.9 19 | github.com/mailru/easyjson v0.7.7 20 | github.com/mattn/go-sqlite3 v1.14.19 21 | github.com/pierrec/lz4 v2.6.1+incompatible 22 | github.com/urfave/cli v1.22.14 23 | gotest.tools v2.2.0+incompatible 24 | ) 25 | 26 | replace github.com/emersion/go-imap => github.com/foxcpp/go-imap v1.0.0-beta.1.0.20220623182312-df940c324887 27 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= 2 | github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 3 | github.com/cpuguy83/go-md2man/v2 v2.0.3 h1:qMCsGGgs+MAzDFyp9LpAe1Lqy/fY/qCovCm0qnXZOBM= 4 | github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 5 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 7 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/emersion/go-imap-appendlimit v0.0.0-20190308131241-25671c986a6a/go.mod h1:ikgISoP7pRAolqsVP64yMteJa2FIpS6ju88eBT6K1yQ= 9 | github.com/emersion/go-imap-move v0.0.0-20180601155324-5eb20cb834bf/go.mod h1:QuMaZcKFDVI0yCrnAbPLfbwllz1wtOrZH8/vZ5yzp4w= 10 | github.com/emersion/go-imap-sortthread v1.2.0 h1:EMVEJXPWAhXMWECjR82Rn/tza6MddcvTwGAdTu1vJKU= 11 | github.com/emersion/go-imap-sortthread v1.2.0/go.mod h1:UhenCBupR+vSYRnqJkpjSq84INUCsyAK1MLpogv14pE= 12 | github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4= 13 | github.com/emersion/go-message v0.18.0 h1:7LxAXHRpSeoO/Wom3ZApVZYG7c3d17yCScYce8WiXA8= 14 | github.com/emersion/go-message v0.18.0/go.mod h1:Zi69ACvzaoV/MBnrxfVBPV3xWEuCmC2nEN39oJF4B8A= 15 | github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= 16 | github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 h1:hH4PQfOndHDlpzYfLAAfl63E8Le6F2+EL/cdhlkyRJY= 17 | github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= 18 | github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 h1:IbFBtwoTQyw0fIM5xv1HF+Y+3ZijDR839WMulgxCcUY= 19 | github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U= 20 | github.com/foxcpp/go-imap v1.0.0-beta.1.0.20220623182312-df940c324887 h1:qUoaaHyrRpQw85ru6VQcC6JowdhrWl7lSbI1zRX1FTM= 21 | github.com/foxcpp/go-imap v1.0.0-beta.1.0.20220623182312-df940c324887/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY= 22 | github.com/foxcpp/go-imap-backend-tests v0.0.0-20220105184719-e80aa29a5e16 h1:qheFPDpteiUy7Ym18R68OYenpk85UyKYGkhYTmddSBg= 23 | github.com/foxcpp/go-imap-backend-tests v0.0.0-20220105184719-e80aa29a5e16/go.mod h1:OPP1AgKxMPo3aHX5pcEZLQhhh5sllFcB8aUN9f6a6X8= 24 | github.com/foxcpp/go-imap-mess v0.0.0-20230108134257-b7ec3a649613 h1:fw9OWfPxP1CK4D+XAEEg0JzhvFGo04L+F5Xw55t9s3E= 25 | github.com/foxcpp/go-imap-mess v0.0.0-20230108134257-b7ec3a649613/go.mod h1:P/O/qz4gaVkefzJ40BUtN/ZzBnaEg0YYe1no/SMp7Aw= 26 | github.com/foxcpp/go-imap-namespace v0.0.0-20200802091432-08496dd8e0ed h1:1Jo7geyvunrPSjL6F6D9EcXoNApS5v3LQaro7aUNPnE= 27 | github.com/foxcpp/go-imap-namespace v0.0.0-20200802091432-08496dd8e0ed/go.mod h1:Shows1vmkBWO40ChOClaUe6DUnZrsP1UPAuoWzIUdgQ= 28 | github.com/frankban/quicktest v1.5.0 h1:Tb4jWdSpdjKzTUicPnY61PZxKbDoGa7ABbrReT3gQVY= 29 | github.com/frankban/quicktest v1.5.0/go.mod h1:jaStnuzAqU1AJdCO0l53JDCJrVDKcS03DbaAcR7Ks/o= 30 | github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI= 31 | github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= 32 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 33 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 34 | github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= 35 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 36 | github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= 37 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 38 | github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= 39 | github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= 40 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 41 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 42 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 43 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 44 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 45 | github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 46 | github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 47 | github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= 48 | github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 49 | github.com/martinlindhe/base36 v1.0.0/go.mod h1:+AtEs8xrBpCeYgSLoY/aJ6Wf37jtBuR0s35750M27+8= 50 | github.com/mattn/go-sqlite3 v1.14.19 h1:fhGleo2h1p8tVChob4I9HpmVFIAkKGpiukdrgQbWfGI= 51 | github.com/mattn/go-sqlite3 v1.14.19/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= 52 | github.com/pierrec/lz4 v2.6.1+incompatible h1:9UY3+iC23yxF0UfGaYrGplQ+79Rg+h/q9FV9ix19jjM= 53 | github.com/pierrec/lz4 v2.6.1+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= 54 | github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= 55 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 56 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 57 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 58 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 59 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 60 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 61 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 62 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 63 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 64 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 65 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 66 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 67 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 68 | github.com/urfave/cli v1.22.14 h1:ebbhrRiGK2i4naQJr+1Xj92HXZCrK7MsyTS/ob3HnAk= 69 | github.com/urfave/cli v1.22.14/go.mod h1:X0eDS6pD6Exaclxm99NJ3FiCDRED7vIHpx2mDOHLvkA= 70 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 71 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 72 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 73 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 74 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 75 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 76 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 77 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 78 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 79 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 80 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 81 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 82 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 83 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 84 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 85 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 86 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 87 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 88 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 89 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 90 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 91 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 92 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 93 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 94 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 95 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 96 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 97 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 98 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 99 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 100 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 101 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 102 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 103 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= 104 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 105 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 106 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 107 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 108 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 109 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 110 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 111 | gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= 112 | gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= 113 | -------------------------------------------------------------------------------- /imaptest.md: -------------------------------------------------------------------------------- 1 | # imaptest status 2 | 3 | ``` 4 | 35 test groups: 9 failed, 0 skipped due to missing capabilities 5 | base protocol: 10/366 individual commands failed 6 | extensions: 16/26 individual commands failed 7 | ``` 8 | 9 | ## Known issues 10 | 11 | ``` 12 | *** Test fetch-envelope command 1/2 (line 3) 13 | - failed: Missing 2 untagged replies (2 mismatches) 14 | - first unexpanded: 4 FETCH ($!unordered=2 ENVELOPE ("Thu, 15 Feb 2007 01:02:03 +0200" NIL (("Real Name" NIL "user" "domain")) (("Real Name" NIL "user" "domain")) (("Real Name" NIL "user" "domain")) ((NIL NIL "group" NIL) (NIL NIL "g1" "d1.org") (NIL NIL "g2" "d2.org") (NIL NIL NIL NIL) (NIL NIL "group2" NIL) (NIL NIL "g3" "d3.org") (NIL NIL NIL NIL)) ((NIL NIL "group" NIL) (NIL NIL NIL NIL) (NIL NIL "group2" NIL) (NIL NIL NIL NIL)) NIL NIL NIL)) 15 | - first expanded: 4 FETCH ( ENVELOPE ("Thu, 15 Feb 2007 01:02:03 +0200" NIL (("Real Name" NIL "user" "domain")) (("Real Name" NIL "user" "domain")) (("Real Name" NIL "user" "domain")) ((NIL NIL "group" NIL) (NIL NIL "g1" "d1.org") (NIL NIL "g2" "d2.org") (NIL NIL NIL NIL) (NIL NIL "group2" NIL) (NIL NIL "g3" "d3.org") (NIL NIL NIL NIL)) ((NIL NIL "group" NIL) (NIL NIL NIL NIL) (NIL NIL "group2" NIL) (NIL NIL NIL NIL)) NIL NIL NIL)) 16 | - best match: 4 FETCH (ENVELOPE ("Thu, 15 Feb 2007 01:02:03 +0200" NIL (("Real Name" NIL "user" "domain")) (("Real Name" NIL "user" "domain")) (("Real Name" NIL "user" "domain")) ((NIL NIL "g1" "d1.org") (NIL NIL "g2" "d2.org") (NIL NIL "g3" "d3.org")) NIL NIL NIL NIL)) 17 | - Command: fetch 1:* envelope 18 | ``` 19 | 20 | No support for RFC 2822 group syntax in envelope parser. 21 | 22 | ``` 23 | *** Test search-addresses command 1/29 (line 3) 24 | - failed: Missing 1 untagged replies (1 mismatches) 25 | - first unexpanded: search 1 2 3 4 6 7 26 | - first expanded: search 1 2 3 4 6 7 27 | - best match: SEARCH 1 2 4 6 7 28 | - Command: search from user-from@domain.org 29 | ``` 30 | 31 | No support for addresses with comments in search code. 32 | 33 | ``` 34 | *** Test search-size command 2/8 (line 9) 35 | - failed: Missing 1 untagged replies (1 mismatches) 36 | - first unexpanded: search 1 2 37 | - first expanded: search 1 2 38 | - best match: SEARCH 1 2 3 4 39 | - Command: search smaller $size 40 | 41 | *** Test search-size command 3/8 (line 11) 42 | - failed: Missing 1 untagged replies (1 mismatches) 43 | - first unexpanded: search 4 44 | - first expanded: search 4 45 | - best match: SEARCH 46 | - Command: search larger $size 47 | 48 | *** Test search-size command 4/8 (line 13) 49 | - failed: Missing 1 untagged replies (1 mismatches) 50 | - first unexpanded: search 3 4 51 | - first expanded: search 3 4 52 | - best match: SEARCH 53 | - Command: search not smaller $size 54 | 55 | *** Test search-size command 5/8 (line 15) 56 | - failed: Missing 1 untagged replies (1 mismatches) 57 | - first unexpanded: search 1 2 3 58 | - first expanded: search 1 2 3 59 | - best match: SEARCH 1 2 3 4 60 | - Command: search not larger $size 61 | 62 | *** Test search-size command 6/8 (line 18) 63 | - failed: Missing 1 untagged replies (1 mismatches) 64 | - first unexpanded: search 3 65 | - first expanded: search 3 66 | - best match: SEARCH 67 | - Command: search not smaller $size not larger $size 68 | 69 | *** Test search-size command 7/8 (line 20) 70 | - failed: Missing 1 untagged replies (1 mismatches) 71 | - first unexpanded: search 1 2 4 72 | - first expanded: search 1 2 4 73 | - best match: SEARCH 1 2 3 4 74 | - Command: search or smaller $size larger $size 75 | ``` 76 | 77 | Size matcher fails to account for header fields size. -------------------------------------------------------------------------------- /log.go: -------------------------------------------------------------------------------- 1 | package imapsql 2 | 3 | import ( 4 | "log" 5 | "strconv" 6 | ) 7 | 8 | type globalLogger struct{} 9 | 10 | func (globalLogger) Printf(format string, v ...interface{}) { 11 | log.Printf(format, v...) 12 | } 13 | 14 | func (globalLogger) Println(v ...interface{}) { 15 | log.Println(v...) 16 | } 17 | 18 | func (globalLogger) Debugf(format string, v ...interface{}) { 19 | log.Printf(format, v...) 20 | } 21 | 22 | func (globalLogger) Debugln(v ...interface{}) { 23 | log.Println(v...) 24 | } 25 | 26 | type DummyLogger struct{} 27 | 28 | func (DummyLogger) Printf(format string, v ...interface{}) {} 29 | func (DummyLogger) Println(v ...interface{}) {} 30 | func (DummyLogger) Debugf(format string, v ...interface{}) {} 31 | func (DummyLogger) Debugln(v ...interface{}) {} 32 | 33 | func (b *Backend) logUserErr(u *User, err error, when string, args ...interface{}) { 34 | if err == nil { 35 | return 36 | } 37 | b.Opts.Log.Printf("%s %v: %v \t{\"username\":%s,\"uid\":%d}", 38 | when, args, err, strconv.Quote(u.username), u.id) 39 | } 40 | 41 | func (b *Backend) logMboxErr(m *Mailbox, err error, when string, args ...interface{}) { 42 | if err == nil { 43 | return 44 | } 45 | b.Opts.Log.Printf("%s %v: %v \t{\"mbox\":%s,\"mboxId\":%d,\"username\":%s,\"uid\":%d}", 46 | when, args, err, strconv.Quote(m.name), m.id, strconv.Quote(m.user.username), m.user.id) 47 | } 48 | -------------------------------------------------------------------------------- /lz4_test.go: -------------------------------------------------------------------------------- 1 | package imapsql 2 | 3 | import ( 4 | "io/ioutil" 5 | "log" 6 | "math/rand" 7 | "os" 8 | "path/filepath" 9 | "testing" 10 | 11 | backendtests "github.com/foxcpp/go-imap-backend-tests" 12 | _ "github.com/go-sql-driver/mysql" 13 | _ "github.com/lib/pq" 14 | _ "github.com/mattn/go-sqlite3" 15 | ) 16 | 17 | func initTestBackendLZ4() backendtests.Backend { 18 | driver := TestDB 19 | dsn := TestDSN 20 | 21 | if TestDB == "" { 22 | driver = "sqlite3" 23 | dsn = ":memory:" 24 | } 25 | 26 | randSrc := rand.NewSource(0) 27 | prng := rand.New(randSrc) 28 | 29 | tempDir, err := ioutil.TempDir("", "go-imap-sql-tests-") 30 | if err != nil { 31 | panic(err) 32 | } 33 | 34 | // This is meant for DB debugging. 35 | if os.Getenv("PRESERVE_SQLITE3_DB") == "1" { 36 | log.Println("Using sqlite3 DB in temporary directory.") 37 | driver = "sqlite3" 38 | dsn = filepath.Join(tempDir, "test.db") 39 | } 40 | 41 | storeDir := filepath.Join(tempDir, "store") 42 | if err := os.MkdirAll(storeDir, os.ModeDir|os.ModePerm); err != nil { 43 | panic(err) 44 | } 45 | 46 | b, err := New(driver, dsn, &FSStore{Root: storeDir}, Opts{ 47 | CompressAlgo: "lz4", 48 | PRNG: prng, 49 | Log: DummyLogger{}, 50 | }) 51 | if err != nil { 52 | panic(err) 53 | } 54 | return b 55 | } 56 | 57 | func TestWithLZ4(t *testing.T) { 58 | backendtests.RunTests(t, initTestBackendLZ4, cleanBackend) 59 | } 60 | -------------------------------------------------------------------------------- /reference_counter_test.go: -------------------------------------------------------------------------------- 1 | package imapsql 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "strings" 7 | "testing" 8 | "time" 9 | 10 | "github.com/emersion/go-imap" 11 | "gotest.tools/assert" 12 | is "gotest.tools/assert/cmp" 13 | ) 14 | 15 | func checkKeysCount(b *Backend, expected int) is.Comparison { 16 | return func() is.Result { 17 | dirList, err := ioutil.ReadDir(b.extStore.(*FSStore).Root) 18 | if err != nil { 19 | return is.ResultFromError(err) 20 | } 21 | if len(dirList) != expected { 22 | names := make([]string, 0, len(dirList)) 23 | for _, ent := range dirList { 24 | names = append(names, ent.Name()) 25 | } 26 | return is.ResultFailure(fmt.Sprintf("expected %d keys to be stored, got %d: %v", expected, len(dirList), names)) 27 | } 28 | return is.ResultSuccess 29 | } 30 | } 31 | 32 | func TestKeyIsRemovedWithMsg(t *testing.T) { 33 | b := initTestBackend().(*Backend) 34 | defer cleanBackend(b) 35 | assert.NilError(t, b.CreateUser(t.Name())) 36 | usr, err := b.GetUser(t.Name()) 37 | assert.NilError(t, err) 38 | assert.NilError(t, usr.CreateMailbox(t.Name())) 39 | _, mbox, err := usr.GetMailbox(t.Name(), true, &noopConn{}) 40 | assert.NilError(t, err) 41 | defer mbox.Close() 42 | 43 | // Message is created, there should be a key. 44 | assert.NilError(t, usr.CreateMessage(mbox.Name(), []string{imap.DeletedFlag}, time.Now(), strings.NewReader(testMsg), mbox)) 45 | assert.NilError(t, mbox.Poll(true)) 46 | assert.Assert(t, checkKeysCount(b, 1), "Wrong amount of external store keys created") 47 | 48 | // Message is removed, there should be no key anymore. 49 | assert.NilError(t, mbox.Expunge()) 50 | assert.Assert(t, checkKeysCount(b, 0), "Key is not removed after message removal") 51 | } 52 | 53 | func TestKeyIsRemovedWithMbox(t *testing.T) { 54 | b := initTestBackend().(*Backend) 55 | defer cleanBackend(b) 56 | assert.NilError(t, b.CreateUser(t.Name())) 57 | usr, err := b.GetUser(t.Name()) 58 | assert.NilError(t, err) 59 | assert.NilError(t, usr.CreateMailbox(t.Name())) 60 | _, mbox, err := usr.GetMailbox(t.Name(), true, &noopConn{}) 61 | assert.NilError(t, err) 62 | 63 | // Message is created, there should be a key. 64 | assert.NilError(t, usr.CreateMessage(mbox.Name(), []string{imap.DeletedFlag}, time.Now(), strings.NewReader(testMsg), mbox)) 65 | assert.NilError(t, mbox.Poll(true)) 66 | assert.Assert(t, checkKeysCount(b, 1), "Wrong amount of external store keys created") 67 | 68 | // The mbox is removed along with all messages, there should be no key anymore. 69 | assert.NilError(t, usr.DeleteMailbox(t.Name())) 70 | assert.Assert(t, checkKeysCount(b, 0), "Key is not removed after mbox removal") 71 | } 72 | 73 | func TestKeyIsRemovedWithCopiedMsgs(t *testing.T) { 74 | b := initTestBackend().(*Backend) 75 | defer cleanBackend(b) 76 | assert.NilError(t, b.CreateUser(t.Name())) 77 | usr, err := b.GetUser(t.Name()) 78 | assert.NilError(t, err) 79 | 80 | assert.NilError(t, usr.CreateMailbox(t.Name()+"-1")) 81 | _, mbox1, err := usr.GetMailbox(t.Name()+"-1", true, &noopConn{}) 82 | assert.NilError(t, err) 83 | defer mbox1.Close() 84 | 85 | assert.NilError(t, usr.CreateMailbox(t.Name()+"-2")) 86 | _, mbox2, err := usr.GetMailbox(t.Name()+"-2", true, &noopConn{}) 87 | assert.NilError(t, err) 88 | defer mbox2.Close() 89 | 90 | // The message is created, there should be a key. 91 | assert.NilError(t, usr.CreateMessage(mbox1.Name(), []string{imap.DeletedFlag}, time.Now(), strings.NewReader(testMsg), mbox1)) 92 | assert.NilError(t, mbox1.Poll(true)) 93 | assert.Assert(t, checkKeysCount(b, 1), "Wrong amount of external store keys created") 94 | 95 | // The message is copied, there should be no duplicate key. 96 | seq, _ := imap.ParseSeqSet("1") 97 | assert.NilError(t, mbox1.CopyMessages(false, seq, mbox2.Name())) 98 | assert.NilError(t, mbox2.Poll(true)) 99 | assert.Assert(t, checkKeysCount(b, 1), "Wrong amount of external store keys") 100 | 101 | // The message copy is removed, key should be still here. 102 | assert.NilError(t, mbox2.Expunge()) 103 | assert.Assert(t, checkKeysCount(b, 1), "Wrong amount of external store keys") 104 | 105 | // Both messages are deleted, there should be no key anymore. 106 | assert.NilError(t, mbox1.Expunge()) 107 | assert.Assert(t, checkKeysCount(b, 0), "Key is not removed after message removal") 108 | } 109 | 110 | func TestKeyIsRemovedWithUser(t *testing.T) { 111 | b := initTestBackend().(*Backend) 112 | defer cleanBackend(b) 113 | assert.NilError(t, b.CreateUser(t.Name())) 114 | usr, err := b.GetUser(t.Name()) 115 | assert.NilError(t, err) 116 | assert.NilError(t, usr.CreateMailbox(t.Name())) 117 | _, mbox, err := usr.GetMailbox(t.Name(), true, &noopConn{}) 118 | assert.NilError(t, err) 119 | defer mbox.Close() 120 | 121 | // The message is created, there should be a key. 122 | assert.NilError(t, usr.CreateMessage(mbox.Name(), []string{imap.DeletedFlag}, time.Now(), strings.NewReader(testMsg), mbox)) 123 | assert.Assert(t, checkKeysCount(b, 1), "Wrong amount of external store keys created") 124 | 125 | // The user account is removed, all keys should be gone. 126 | assert.NilError(t, b.DeleteUser(usr.Username())) 127 | assert.Assert(t, checkKeysCount(b, 0), "Key is not removed after message removal") 128 | } 129 | -------------------------------------------------------------------------------- /regress_test.go: -------------------------------------------------------------------------------- 1 | package imapsql 2 | 3 | import ( 4 | "io/ioutil" 5 | "strings" 6 | "testing" 7 | "time" 8 | 9 | "github.com/emersion/go-imap" 10 | "github.com/emersion/go-imap/backend" 11 | "gotest.tools/assert" 12 | is "gotest.tools/assert/cmp" 13 | ) 14 | 15 | const ( 16 | testMsgHeader = "From: \r\n" + 17 | "Subject: Hello!\r\n" + 18 | "Content-Type: text/plain; charset=ascii\r\n" + 19 | "Non-Cached-Header: 1\r\n" + 20 | "\r\n" 21 | testMsgBody = "Hello!\r\n" 22 | testMsg = testMsgHeader + 23 | testMsgBody 24 | ) 25 | 26 | type collectorConn struct { 27 | upds []backend.Update 28 | } 29 | 30 | func (c *collectorConn) SendUpdate(upd backend.Update) error { 31 | c.upds = append(c.upds, upd) 32 | return nil 33 | } 34 | 35 | func TestRecentIncorrectReset(t *testing.T) { 36 | b := initTestBackend() 37 | defer cleanBackend(b) 38 | assert.NilError(t, b.CreateUser(t.Name())) 39 | usr, err := b.GetUser(t.Name()) 40 | assert.NilError(t, err) 41 | assert.NilError(t, usr.CreateMailbox(t.Name())) 42 | 43 | for i := 0; i < 5; i++ { 44 | assert.NilError(t, usr.CreateMessage(t.Name(), []string{"flag1", "flag2"}, time.Now(), strings.NewReader(testMsg), nil)) 45 | } 46 | 47 | conn := collectorConn{} 48 | info, mbox, err := usr.GetMailbox(t.Name(), false, &conn) 49 | assert.NilError(t, err) 50 | assert.Equal(t, info.Messages, uint32(5)) 51 | assert.Equal(t, info.Recent, uint32(5)) 52 | 53 | assert.NilError(t, usr.CreateMessage(t.Name(), []string{"flag1", "flag2"}, time.Now(), strings.NewReader(testMsg), mbox)) 54 | assert.NilError(t, mbox.Poll(true)) 55 | assert.Equal(t, conn.upds[1].(*backend.MailboxUpdate).Recent, uint32(6)) 56 | 57 | assert.NilError(t, mbox.Close()) 58 | info, mbox, err = usr.GetMailbox(t.Name(), false, &conn) 59 | assert.NilError(t, err) 60 | assert.Equal(t, info.Messages, uint32(6)) 61 | assert.Equal(t, info.Recent, uint32(0)) 62 | } 63 | 64 | func TestIssue7(t *testing.T) { 65 | b := initTestBackend() 66 | defer cleanBackend(b) 67 | assert.NilError(t, b.CreateUser(t.Name())) 68 | usr, err := b.GetUser(t.Name()) 69 | assert.NilError(t, err) 70 | assert.NilError(t, usr.CreateMailbox(t.Name())) 71 | _, mbox, err := usr.GetMailbox(t.Name(), true, &noopConn{}) 72 | assert.NilError(t, err) 73 | for i := 0; i < 5; i++ { 74 | assert.NilError(t, usr.CreateMessage(mbox.Name(), []string{"flag1", "flag2"}, time.Now(), strings.NewReader(testMsg), nil)) 75 | } 76 | 77 | t.Run("seq", func(t *testing.T) { 78 | crit := imap.SearchCriteria{} 79 | seqs, err := mbox.SearchMessages(false, &crit) 80 | assert.NilError(t, err) 81 | 82 | t.Log("Seq. nums.:", seqs) 83 | 84 | seenSeq := make(map[uint32]bool) 85 | for _, seq := range seqs { 86 | assert.Check(t, !seenSeq[seq], "Duplicate sequence number in SEARCH ALL response") 87 | seenSeq[seq] = true 88 | } 89 | }) 90 | t.Run("uid", func(t *testing.T) { 91 | crit := imap.SearchCriteria{} 92 | uids, err := mbox.SearchMessages(true, &crit) 93 | assert.NilError(t, err) 94 | 95 | t.Log("UIDs:", uids) 96 | 97 | seenUids := make(map[uint32]bool) 98 | for _, uid := range uids { 99 | assert.Check(t, !seenUids[uid], "Duplicate UID in SEARCH ALL response") 100 | seenUids[uid] = true 101 | } 102 | }) 103 | } 104 | 105 | func TestDuplicateSearchWithoutFlags(t *testing.T) { 106 | b := initTestBackend() 107 | defer cleanBackend(b) 108 | assert.NilError(t, b.CreateUser(t.Name())) 109 | usr, err := b.GetUser(t.Name()) 110 | assert.NilError(t, err) 111 | assert.NilError(t, usr.CreateMailbox(t.Name())) 112 | _, mbox, err := usr.GetMailbox(t.Name(), true, &noopConn{}) 113 | assert.NilError(t, err) 114 | for i := 0; i < 5; i++ { 115 | assert.NilError(t, usr.CreateMessage(mbox.Name(), []string{"flag1", "flag2"}, time.Now(), strings.NewReader(testMsg), mbox)) 116 | } 117 | assert.NilError(t, mbox.Poll(true)) 118 | 119 | res, err := mbox.SearchMessages(true, &imap.SearchCriteria{ 120 | WithoutFlags: []string{"flag3"}, 121 | }) 122 | assert.NilError(t, err) 123 | assert.DeepEqual(t, res, []uint32{1, 2, 3, 4, 5}) 124 | 125 | res, err = mbox.SearchMessages(false, &imap.SearchCriteria{ 126 | WithoutFlags: []string{"flag3"}, 127 | }) 128 | assert.NilError(t, err) 129 | assert.DeepEqual(t, res, []uint32{1, 2, 3, 4, 5}) 130 | } 131 | 132 | func TestHeaderInMultipleBodyFetch(t *testing.T) { 133 | test := func(t *testing.T, fetchItems []imap.FetchItem) { 134 | b := initTestBackend() 135 | defer cleanBackend(b) 136 | assert.NilError(t, b.CreateUser(t.Name())) 137 | usr, err := b.GetUser(t.Name()) 138 | assert.NilError(t, err) 139 | assert.NilError(t, usr.CreateMailbox(t.Name())) 140 | _, mbox, err := usr.GetMailbox(t.Name(), true, &noopConn{}) 141 | assert.NilError(t, err) 142 | for i := 0; i < 5; i++ { 143 | assert.NilError(t, usr.CreateMessage(mbox.Name(), []string{}, time.Now(), strings.NewReader(testMsg), nil)) 144 | } 145 | assert.NilError(t, mbox.Poll(true)) 146 | 147 | seq, _ := imap.ParseSeqSet("1") 148 | ch := make(chan *imap.Message, 5) 149 | assert.NilError(t, mbox.ListMessages(false, seq, fetchItems, ch), "ListMessages") 150 | assert.Assert(t, is.Len(ch, 1)) 151 | msg := <-ch 152 | 153 | for name, literal := range msg.Body { 154 | blob, err := ioutil.ReadAll(literal) 155 | assert.NilError(t, err, "ReadAll literal") 156 | switch name.FetchItem() { 157 | case "BODY.PEEK[HEADER]": 158 | assert.Equal(t, string(blob), testMsgHeader) 159 | case "BODY.PEEK[TEXT]": 160 | assert.Equal(t, string(blob), testMsgBody) 161 | } 162 | } 163 | } 164 | 165 | t.Run("text/text", func(t *testing.T) { 166 | test(t, []imap.FetchItem{"BODY.PEEK[TEXT]", "BODY.PEEK[TEXT]"}) 167 | }) 168 | t.Run("header/text", func(t *testing.T) { 169 | test(t, []imap.FetchItem{"BODY.PEEK[HEADER]", "BODY.PEEK[TEXT]"}) 170 | }) 171 | } 172 | 173 | func TestHeaderCacheReuse(t *testing.T) { 174 | b := initTestBackend() 175 | defer cleanBackend(b) 176 | assert.NilError(t, b.CreateUser(t.Name())) 177 | usr, err := b.GetUser(t.Name()) 178 | assert.NilError(t, err) 179 | assert.NilError(t, usr.CreateMailbox(t.Name())) 180 | _, mbox, err := usr.GetMailbox(t.Name(), true, &noopConn{}) 181 | assert.NilError(t, err) 182 | 183 | testComplete := "Subject: Test\r\n\r\nBody text" 184 | testMissingSubject := "Another-Field: Test\r\n\r\nBody text" 185 | 186 | assert.NilError(t, usr.CreateMessage(mbox.Name(), []string{}, time.Now(), strings.NewReader(testComplete), nil)) 187 | assert.NilError(t, usr.CreateMessage(mbox.Name(), []string{}, time.Now(), strings.NewReader(testMissingSubject), nil)) 188 | assert.NilError(t, mbox.Poll(true)) 189 | 190 | t.Run("envelope", func(t *testing.T) { 191 | seq, _ := imap.ParseSeqSet("1:*") 192 | ch := make(chan *imap.Message, 2) 193 | assert.NilError(t, mbox.ListMessages(false, seq, []imap.FetchItem{imap.FetchEnvelope}, ch), "ListMessages") 194 | assert.Assert(t, is.Len(ch, 2)) 195 | <-ch 196 | msg2 := <-ch 197 | 198 | assert.DeepEqual(t, msg2.Envelope.Subject, "") 199 | }) 200 | } 201 | 202 | func TestSearchEmptyFlags(t *testing.T) { 203 | b := initTestBackend() 204 | defer cleanBackend(b) 205 | assert.NilError(t, b.CreateUser(t.Name())) 206 | usr, err := b.GetUser(t.Name()) 207 | assert.NilError(t, err) 208 | assert.NilError(t, usr.CreateMailbox(t.Name())) 209 | _, mbox, err := usr.GetMailbox(t.Name(), true, &noopConn{}) 210 | assert.NilError(t, err) 211 | for i := 0; i < 3; i++ { 212 | assert.NilError(t, usr.CreateMessage(mbox.Name(), []string{}, time.Now(), strings.NewReader(testMsg), mbox)) 213 | } 214 | 215 | // creating '\Deleted' message to ensure flag checks are properly working 216 | assert.NilError(t, usr.CreateMessage(mbox.Name(), []string{"\\Deleted"}, time.Now(), strings.NewReader(testMsg), mbox)) 217 | 218 | assert.NilError(t, mbox.Poll(true)) 219 | 220 | res, err := mbox.SearchMessages(true, &imap.SearchCriteria{ 221 | WithoutFlags: []string{"\\Deleted"}, 222 | }) 223 | assert.NilError(t, err) 224 | assert.DeepEqual(t, res, []uint32{1, 2, 3}) 225 | 226 | res, err = mbox.SearchMessages(false, &imap.SearchCriteria{ 227 | WithoutFlags: []string{"\\Seen"}, 228 | }) 229 | assert.NilError(t, err) 230 | assert.DeepEqual(t, res, []uint32{1, 2, 3, 4}) 231 | } 232 | -------------------------------------------------------------------------------- /schema.go: -------------------------------------------------------------------------------- 1 | package imapsql 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | ) 7 | 8 | func (b *Backend) schemaVersion() (int, error) { 9 | _, err := b.db.Exec(`CREATE TABLE IF NOT EXISTS schema_version ( version INTEGER NOT NULL )`) 10 | if err != nil { 11 | return 0, err 12 | } 13 | 14 | row := b.db.QueryRow(`SELECT version FROM schema_version`) 15 | var version int 16 | if err := row.Scan(&version); err != nil { 17 | if err == sql.ErrNoRows { 18 | return 0, nil 19 | } 20 | return 0, err 21 | } 22 | return version, nil 23 | } 24 | 25 | func (b *Backend) setSchemaVersion(newVer int) error { 26 | _, err := b.db.Exec(`CREATE TABLE IF NOT EXISTS schema_version ( version INTEGER NOT NULL )`) 27 | if err != nil { 28 | return err 29 | } 30 | 31 | info, err := b.db.Exec(`UPDATE schema_version SET version = ?`, newVer) 32 | if err != nil { 33 | return err 34 | } 35 | affected, err := info.RowsAffected() 36 | if err != nil { 37 | return err 38 | } 39 | 40 | if affected == 0 { 41 | _, err = b.db.Exec(`INSERT INTO schema_version VALUES (?)`, newVer) 42 | if err != nil { 43 | return err 44 | } 45 | } 46 | 47 | return nil 48 | } 49 | 50 | func (b *Backend) upgradeSchema(currentVer int) error { 51 | tx, err := b.db.Begin(false) 52 | if err != nil { 53 | return err 54 | } 55 | defer tx.Rollback() 56 | 57 | // Functions for schema upgrade go here. Example: 58 | //if currentVer == 1 { 59 | // if err := b.schemaUpgrade1To2(tx); err != nil { 60 | // return wrapErr(err, "1->2 upgrade") 61 | // } 62 | // currentVer = 2 63 | //} 64 | 65 | if currentVer == 5 { 66 | _, err = b.DB.Exec(`ALTER TABLE msgs ADD COLUMN recent INTEGER NOT NULL DEFAULT 1`) 67 | if err != nil { 68 | return wrapErr(err, "5->6 upgrade") 69 | } 70 | currentVer = 6 71 | } 72 | 73 | if currentVer != SchemaVersion { 74 | return errors.New("database schema version is too old and can't be upgraded using this go-imap-sql version") 75 | } 76 | return tx.Commit() 77 | } 78 | -------------------------------------------------------------------------------- /search.go: -------------------------------------------------------------------------------- 1 | package imapsql 2 | 3 | import ( 4 | "database/sql" 5 | "strings" 6 | "time" 7 | 8 | "github.com/emersion/go-imap" 9 | "github.com/emersion/go-imap/backend/backendutil" 10 | "github.com/emersion/go-message" 11 | "github.com/emersion/go-message/textproto" 12 | ) 13 | 14 | func (m *Mailbox) SearchMessages(uid bool, criteria *imap.SearchCriteria) ([]uint32, error) { 15 | if searchOnlyWithFlags(criteria) { 16 | if criteria.Not == nil && criteria.Or == nil && criteria.WithFlags == nil && criteria.WithoutFlags == nil { 17 | return m.allSearch(uid) 18 | } 19 | 20 | return m.flagSearch(uid, criteria.WithFlags, criteria.WithoutFlags) 21 | } 22 | 23 | m.handle.ResolveCriteria(criteria) 24 | 25 | needBody := searchNeedsBody(criteria) 26 | rows, err := m.parent.searchFetchNoSeq.Query(m.id) 27 | if err != nil { 28 | return nil, err 29 | } 30 | defer rows.Close() 31 | 32 | var res []uint32 33 | for rows.Next() { 34 | id, err := m.searchMatches(uid, needBody, rows, criteria) 35 | if err != nil { 36 | return nil, err 37 | } 38 | if id != 0 { 39 | res = append(res, id) 40 | } 41 | } 42 | if err := rows.Err(); err != nil { 43 | return nil, err 44 | } 45 | 46 | return res, nil 47 | } 48 | 49 | func (m *Mailbox) searchMatches(uid, needBody bool, rows *sql.Rows, criteria *imap.SearchCriteria) (uint32, error) { 50 | var ( 51 | msgId uint32 52 | dateUnix int64 53 | bodyLen int 54 | flagStr string 55 | extBodyKey string 56 | compressAlgo string 57 | ) 58 | 59 | if err := rows.Scan(&msgId, &dateUnix, &bodyLen, &extBodyKey, &compressAlgo, &flagStr); err != nil { 60 | return 0, err 61 | } 62 | 63 | flags := strings.Split(flagStr, flagsSep) 64 | if len(flags) == 1 && flags[0] == "" { 65 | flags = nil 66 | } 67 | 68 | var ent *message.Entity 69 | var err error 70 | if needBody { 71 | bufferedBody, err := m.openBody(true, compressAlgo, extBodyKey) 72 | if err != nil { 73 | m.parent.logMboxErr(m, err, "failed to read body, skipping", extBodyKey) 74 | return 0, nil 75 | } 76 | defer bufferedBody.Close() 77 | 78 | hdr, err := textproto.ReadHeader(bufferedBody.Reader) 79 | if err != nil { 80 | m.parent.logMboxErr(m, err, "failed to parse body, skipping", extBodyKey) 81 | return 0, nil 82 | } 83 | 84 | ent, err = message.New(message.Header{Header: hdr}, bufferedBody.Reader) 85 | if err != nil { 86 | m.parent.logMboxErr(m, err, "failed to parse body, skipping", extBodyKey) 87 | return 0, nil 88 | } 89 | } else { 90 | // XXX: This assumes backendutil.Match will not touch body unless it is needed for criteria. 91 | ent, _ = message.New(message.Header{}, nil) 92 | } 93 | 94 | var seqNum uint32 95 | if !uid { 96 | var ok bool 97 | seqNum, ok = m.handle.UidAsSeq(msgId) 98 | if !ok { 99 | // Wtf 100 | return 0, nil 101 | } 102 | } 103 | 104 | matched, err := backendutil.Match(ent, seqNum, msgId, time.Unix(dateUnix, 0), flags, criteria) 105 | if err != nil { 106 | return 0, err 107 | } 108 | if !matched { 109 | return 0, nil 110 | } 111 | 112 | if uid { 113 | return msgId, nil 114 | } else { 115 | return seqNum, nil 116 | } 117 | } 118 | 119 | func searchNeedsBody(criteria *imap.SearchCriteria) bool { 120 | if criteria.Header != nil || 121 | criteria.Body != nil || 122 | criteria.Text != nil || 123 | !criteria.SentSince.IsZero() || 124 | !criteria.SentBefore.IsZero() || 125 | criteria.Smaller != 0 || 126 | criteria.Larger != 0 { 127 | 128 | return true 129 | } 130 | 131 | for _, crit := range criteria.Not { 132 | if searchNeedsBody(crit) { 133 | return true 134 | } 135 | } 136 | for _, crit := range criteria.Or { 137 | if searchNeedsBody(crit[0]) || searchNeedsBody(crit[1]) { 138 | return true 139 | } 140 | } 141 | 142 | return false 143 | } 144 | 145 | func searchOnlyWithFlags(criteria *imap.SearchCriteria) bool { 146 | if criteria.Header != nil || 147 | criteria.Body != nil || 148 | criteria.Text != nil || 149 | !criteria.SentSince.IsZero() || 150 | !criteria.SentBefore.IsZero() || 151 | criteria.Smaller != 0 || 152 | criteria.Uid != nil || 153 | criteria.SeqNum != nil || 154 | !criteria.Since.IsZero() || 155 | !criteria.Before.IsZero() || 156 | criteria.Larger != 0 || 157 | criteria.Not != nil || 158 | criteria.Or != nil { 159 | 160 | return false 161 | } 162 | 163 | return true 164 | } 165 | 166 | func noSeqNumNeeded(criteria *imap.SearchCriteria) bool { 167 | if criteria.SeqNum != nil { 168 | return false 169 | } 170 | 171 | for _, crit := range criteria.Not { 172 | if !noSeqNumNeeded(crit) { 173 | return false 174 | } 175 | } 176 | for _, crit := range criteria.Or { 177 | if !noSeqNumNeeded(crit[0]) || !noSeqNumNeeded(crit[1]) { 178 | return false 179 | } 180 | } 181 | 182 | return true 183 | } 184 | 185 | func (m *Mailbox) allSearch(uid bool) ([]uint32, error) { 186 | if !uid { 187 | count := m.handle.MsgsCount() 188 | seqs := make([]uint32, 0, count) 189 | for i := uint32(1); i <= uint32(count); i++ { 190 | seqs = append(seqs, i) 191 | } 192 | return seqs, nil 193 | } 194 | 195 | rows, err := m.parent.listMsgUids.Query(m.id) 196 | if err != nil { 197 | return nil, err 198 | } 199 | 200 | var uids []uint32 201 | for rows.Next() { 202 | var uid uint32 203 | if err := rows.Scan(&uid); err != nil { 204 | return nil, err 205 | } 206 | 207 | uids = append(uids, uid) 208 | } 209 | return uids, nil 210 | } 211 | 212 | func (m *Mailbox) flagSearch(uid bool, withFlags, withoutFlags []string) ([]uint32, error) { 213 | recentRequired := false 214 | recentExcluded := false 215 | newWithFlags := withFlags[:0] 216 | for _, f := range withFlags { 217 | if f == imap.RecentFlag { 218 | recentRequired = true 219 | continue 220 | } 221 | newWithFlags = append(newWithFlags, f) 222 | } 223 | withFlags = newWithFlags 224 | newWithoutFlags := withoutFlags[:0] 225 | for _, f := range withoutFlags { 226 | if f == imap.RecentFlag { 227 | recentExcluded = true 228 | continue 229 | } 230 | newWithoutFlags = append(newWithoutFlags, f) 231 | } 232 | withoutFlags = newWithoutFlags 233 | 234 | stmt, err := m.getFlagSearchStmt(withFlags, withoutFlags) 235 | if err != nil { 236 | return nil, err 237 | } 238 | 239 | args := m.buildFlagSearchQueryArgs(withFlags, withoutFlags) 240 | rows, err := stmt.Query(args...) 241 | if err != nil { 242 | return nil, err 243 | } 244 | 245 | var res []uint32 246 | for rows.Next() { 247 | var id uint32 248 | if err := rows.Scan(&id); err != nil { 249 | return nil, err 250 | } 251 | 252 | // Excluding \Recent from SQL-based search will only extend 253 | // results. Since \Recent is per-connection we cannot use SQL 254 | // index matching to filter by it, therefore we accept 255 | // extended results and filter them additionally. 256 | if recentRequired || recentExcluded { 257 | if m.handle.IsRecent(id) { 258 | if recentExcluded { 259 | continue 260 | } 261 | } else if recentRequired { 262 | continue 263 | } 264 | } 265 | 266 | if !uid { 267 | var ok bool 268 | id, ok = m.handle.UidAsSeq(id) 269 | if !ok { 270 | continue 271 | } 272 | } 273 | res = append(res, id) 274 | } 275 | if err := rows.Err(); err != nil { 276 | return nil, err 277 | } 278 | return res, nil 279 | } 280 | 281 | func matchFlags(flags []string, with, without []string) bool { 282 | flagsSet := make(map[string]bool, len(flags)) 283 | for _, f := range flags { 284 | flagsSet[f] = true 285 | } 286 | 287 | for _, f := range without { 288 | if flagsSet[f] { 289 | return false 290 | } 291 | } 292 | for _, f := range with { 293 | if !flagsSet[f] { 294 | return false 295 | } 296 | } 297 | 298 | return true 299 | } 300 | -------------------------------------------------------------------------------- /sortthread.go: -------------------------------------------------------------------------------- 1 | package imapsql 2 | 3 | import ( 4 | "database/sql" 5 | "encoding/json" 6 | "errors" 7 | "log" 8 | "net/mail" 9 | "sort" 10 | "time" 11 | 12 | "github.com/emersion/go-imap" 13 | sortthread "github.com/emersion/go-imap-sortthread" 14 | ) 15 | 16 | type msgKey struct { 17 | ID uint32 18 | ArrivalUnix int64 19 | BodyLen uint32 20 | CachedHeader map[string][]string 21 | } 22 | 23 | func (m *Mailbox) Sort(uid bool, sortCrit []sortthread.SortCriterion, searchCrit *imap.SearchCriteria) ([]uint32, error) { 24 | m.parent.Opts.Log.Debugln("Sort: SORT", uid, sortCrit, searchCrit) 25 | msgs, err := m.SearchMessages(true, searchCrit) 26 | if err != nil { 27 | return nil, err 28 | } 29 | 30 | if len(msgs) == 0 { 31 | if uid { 32 | return nil, nil 33 | } 34 | return nil, errors.New("No messages matched the criteria") 35 | } 36 | 37 | // IDs in msgs are sorted so this will 'compress' adjacent IDs into ranges. 38 | seqSet := imap.SeqSet{} 39 | seqSet.AddNum(msgs...) 40 | 41 | m.parent.Opts.Log.Debugln("Sort: SORT found uids", seqSet) 42 | 43 | // XXX: Split SearchMessages to allow it running in the same transaction. 44 | 45 | resultCount := len(msgs) 46 | if resultCount > 1000 { 47 | resultCount = 1000 48 | } 49 | sortBuffer := make([]*msgKey, 0, resultCount) 50 | 51 | _, err = m.headerMetaScan(nil, &seqSet, func(k *msgKey) error { 52 | sortBuffer = append(sortBuffer, k) 53 | return nil 54 | }) 55 | if err != nil { 56 | return nil, errors.New("Internal server error") 57 | } 58 | 59 | m.parent.Opts.Log.Debugln("Sort: sorting", len(sortBuffer), "messages") 60 | 61 | sort.Slice(sortBuffer, messageCompare(sortBuffer, sortCrit)) 62 | ids := make([]uint32, len(sortBuffer)) 63 | for i, msg := range sortBuffer { 64 | id := msg.ID 65 | if !uid { 66 | var ok bool 67 | id, ok = m.handle.UidAsSeq(id) 68 | if !ok { 69 | continue // Wtf 70 | } 71 | } 72 | ids[i] = id 73 | } 74 | return ids, nil 75 | } 76 | 77 | func firstHeaderField(all []string) string { 78 | if len(all) > 0 { 79 | return all[0] 80 | } 81 | return "" 82 | } 83 | 84 | func firstAddrFromList(all []string) string { 85 | list, err := mail.ParseAddressList(firstHeaderField(all)) 86 | if err != nil { 87 | return "" 88 | } 89 | if len(list) == 0 { 90 | return "" 91 | } 92 | return list[0].Address 93 | } 94 | 95 | func sentDate(dateHeaders []string, arrivalUnix int64) time.Time { 96 | t, err := parseMessageDateTime(firstHeaderField(dateHeaders)) 97 | if err != nil { 98 | return time.Unix(arrivalUnix, 0) 99 | } 100 | return t.UTC() 101 | } 102 | 103 | func messageCompare(buf []*msgKey, sortCrit []sortthread.SortCriterion) func(i, j int) bool { 104 | return func(i, j int) bool { 105 | for _, crit := range sortCrit { 106 | switch crit.Field { 107 | case "ARRIVAL": 108 | if buf[i].ArrivalUnix == buf[j].ArrivalUnix { 109 | continue 110 | } 111 | if crit.Reverse { 112 | return buf[i].ArrivalUnix > buf[j].ArrivalUnix 113 | } else { 114 | return buf[i].ArrivalUnix < buf[j].ArrivalUnix 115 | } 116 | case "CC": 117 | iAddr := firstAddrFromList(buf[i].CachedHeader["Cc"]) 118 | jAddr := firstAddrFromList(buf[j].CachedHeader["Cc"]) 119 | if iAddr == jAddr { 120 | continue 121 | } 122 | if crit.Reverse { 123 | return iAddr > jAddr 124 | } else { 125 | return iAddr < jAddr 126 | } 127 | case "DATE": 128 | iDate := sentDate(buf[i].CachedHeader["Date"], buf[i].ArrivalUnix) 129 | jDate := sentDate(buf[j].CachedHeader["Date"], buf[j].ArrivalUnix) 130 | if iDate == jDate { 131 | continue 132 | } 133 | if crit.Reverse { 134 | return iDate.After(jDate) 135 | } else { 136 | return iDate.Before(jDate) 137 | } 138 | case "FROM": 139 | iAddr := firstAddrFromList(buf[i].CachedHeader["From"]) 140 | jAddr := firstAddrFromList(buf[j].CachedHeader["From"]) 141 | log.Println(iAddr, "vs", jAddr, "=>", iAddr < jAddr) 142 | if iAddr == jAddr { 143 | continue 144 | } 145 | if crit.Reverse { 146 | return iAddr > jAddr 147 | } else { 148 | return iAddr < jAddr 149 | } 150 | case "SIZE": 151 | if buf[i].BodyLen == buf[j].BodyLen { 152 | continue 153 | } 154 | if crit.Reverse { 155 | return buf[i].BodyLen > buf[j].BodyLen 156 | } else { 157 | return buf[i].BodyLen < buf[j].BodyLen 158 | } 159 | case "SUBJECT": 160 | iSubj, _ := sortthread.GetBaseSubject(firstHeaderField(buf[i].CachedHeader["Subject"])) 161 | jSubj, _ := sortthread.GetBaseSubject(firstHeaderField(buf[j].CachedHeader["Subject"])) 162 | if iSubj == jSubj { 163 | continue 164 | } 165 | if crit.Reverse { 166 | return iSubj > jSubj 167 | } else { 168 | return iSubj < jSubj 169 | } 170 | case "TO": 171 | iAddr := firstAddrFromList(buf[i].CachedHeader["To"]) 172 | jAddr := firstAddrFromList(buf[j].CachedHeader["To"]) 173 | if iAddr == jAddr { 174 | continue 175 | } 176 | if crit.Reverse { 177 | return iAddr > jAddr 178 | } else { 179 | return iAddr < jAddr 180 | } 181 | } 182 | } 183 | return buf[i].ID < buf[j].ID 184 | } 185 | } 186 | 187 | func (b *Backend) SupportedThreadAlgorithms() []sortthread.ThreadAlgorithm { 188 | return []sortthread.ThreadAlgorithm{sortthread.OrderedSubject} 189 | } 190 | 191 | func (m *Mailbox) Thread(uid bool, threading sortthread.ThreadAlgorithm, searchCrit *imap.SearchCriteria) ([]*sortthread.Thread, error) { 192 | m.parent.Opts.Log.Debugln("Sort: THREAD", uid, threading, searchCrit) 193 | msgs, err := m.SearchMessages(uid, searchCrit) 194 | if err != nil { 195 | return nil, err 196 | } 197 | 198 | if len(msgs) == 0 { 199 | return nil, errors.New("No messages matched the criteria") 200 | } 201 | 202 | // IDs in msgs are sorted so this will 'compress' adjacent IDs into ranges 203 | // and improve meta-data load performance. 204 | seqSet := imap.SeqSet{} 205 | seqSet.AddNum(msgs...) 206 | 207 | // TODO: Split SearchMessages to allow it running in the same transaction. 208 | 209 | if threading != sortthread.OrderedSubject { 210 | return nil, errors.New("Unsupported threading algorithm") 211 | } 212 | 213 | return m.orderedSubjThread(nil, uid, &seqSet, len(msgs)) 214 | } 215 | 216 | func (m *Mailbox) orderedSubjThread(tx *sql.Tx, uid bool, seqSet *imap.SeqSet, msgCount int) ([]*sortthread.Thread, error) { 217 | type msg struct { 218 | id uint32 219 | sentDate int64 220 | } 221 | // Some educated guess for size to reduce amount of reallocations needed for hash map. 222 | // based on assumption that most messages do not have replies or forwards. 223 | threads := make(map[string][]msg, msgCount/9*10) 224 | 225 | count, err := m.headerMetaScan(tx, seqSet, func(k *msgKey) error { 226 | subject, _ := sortthread.GetBaseSubject(firstHeaderField(k.CachedHeader["Subject"])) 227 | sentDate := sentDate(k.CachedHeader["Date"], k.ArrivalUnix) 228 | 229 | if threads[subject] == nil { 230 | threads[subject] = []msg{} 231 | } 232 | threads[subject] = append(threads[subject], msg{ 233 | id: k.ID, 234 | sentDate: sentDate.Unix(), 235 | }) 236 | 237 | m.parent.Opts.Log.Debugln(k.ID, "grouped per", subject, "at", sentDate) 238 | 239 | return nil 240 | }) 241 | if err != nil { 242 | return nil, errors.New("Internal server error") // headerMetaScan logs the actual error 243 | } 244 | seqSet = nil // Hint for GC. 245 | 246 | for _, thread := range threads { 247 | sort.Slice(thread, func(i, j int) bool { 248 | return thread[i].sentDate < thread[j].sentDate 249 | }) 250 | } 251 | sortedThreads := make([][]msg, 0, len(threads)) 252 | for _, thread := range threads { 253 | sortedThreads = append(sortedThreads, thread) 254 | } 255 | threads = nil // Hint for GC. 256 | sort.Slice(sortedThreads, func(i, j int) bool { 257 | // Assertion: No empty threads (threads are only created by callback 258 | // above and have at least one message). 259 | return sortedThreads[i][0].sentDate < sortedThreads[j][0].sentDate 260 | }) 261 | m.parent.Opts.Log.Debugln(len(sortedThreads), "threads", "msgCount:", msgCount) 262 | 263 | // We preallocate space for all Thread structures together 264 | // and then pick one at nodeOffset each set we need one. 265 | threadsTree := make([]sortthread.Thread, count) 266 | nodeOffset := 0 267 | result := make([]*sortthread.Thread, 0, len(threads)) 268 | 269 | for _, thread := range sortedThreads { 270 | current := &threadsTree[nodeOffset] 271 | nodeOffset++ 272 | result = append(result, current) 273 | // Assertion: No empty threads (threads are only created by callback 274 | // above and have at least one message). 275 | current.Id = thread[0].id 276 | for _, msg := range thread[1:] { 277 | next := &threadsTree[nodeOffset] 278 | nodeOffset++ 279 | 280 | id := msg.id 281 | if !uid { 282 | var ok bool 283 | id, ok = m.handle.UidAsSeq(id) 284 | if !ok { 285 | continue // Wtf 286 | } 287 | } 288 | 289 | next.Id = id 290 | current.Children = []*sortthread.Thread{next} 291 | current = next 292 | } 293 | } 294 | 295 | return result, nil 296 | } 297 | 298 | func (m *Mailbox) headerMetaScan(tx *sql.Tx, seqSet *imap.SeqSet, callback func(k *msgKey) error) (int, error) { 299 | count := 0 300 | if tx == nil { 301 | var err error 302 | tx, err = m.parent.db.BeginLevel(sql.LevelReadCommitted, true) 303 | if err != nil { 304 | m.parent.logMboxErr(m, err, "headerMetaScan (tx start)", seqSet) 305 | return 0, err 306 | } 307 | defer tx.Rollback() 308 | } 309 | 310 | outerLoop: 311 | for _, seq := range seqSet.Set { 312 | rows, err := tx.Stmt(m.parent.cachedHeaderUid).Query(m.id, seq.Start, seq.Stop) 313 | if err != nil { 314 | m.parent.logMboxErr(m, err, "headerMetaScan: cachedHeader", seqSet) 315 | return 0, err 316 | } 317 | 318 | for rows.Next() { 319 | var cachedHeaderBlob []byte 320 | key := msgKey{} 321 | if err := rows.Scan(&key.ID, &cachedHeaderBlob, &key.BodyLen, &key.ArrivalUnix); err != nil { 322 | m.parent.logMboxErr(m, err, "headerMetaScan: cachedHeader scan", seqSet) 323 | rows.Close() 324 | continue 325 | } 326 | if err := json.Unmarshal(cachedHeaderBlob, &key.CachedHeader); err != nil { 327 | m.parent.logMboxErr(m, err, "headerMetaScan: cachedHeader unmarshal", seqSet) 328 | rows.Close() 329 | continue 330 | } 331 | 332 | if err := callback(&key); err != nil { 333 | m.parent.logMboxErr(m, err, "headerMetaScan: callback error", seqSet) 334 | rows.Close() 335 | return 0, err 336 | } 337 | 338 | count++ 339 | if count == 10000 { 340 | rows.Close() 341 | break outerLoop 342 | } 343 | } 344 | rows.Close() 345 | } 346 | 347 | return count, nil 348 | } 349 | -------------------------------------------------------------------------------- /sql_fetch.go: -------------------------------------------------------------------------------- 1 | package imapsql 2 | 3 | import ( 4 | "database/sql" 5 | nettextproto "net/textproto" 6 | "sort" 7 | "strings" 8 | 9 | "github.com/emersion/go-imap" 10 | ) 11 | 12 | const flagsMidBlock = ` 13 | LEFT JOIN flags 14 | ON flags.msgId = msgs.msgId AND msgs.mboxId = flags.mboxId` 15 | 16 | var cachedHeaderFields = map[string]struct{}{ 17 | // Common header fields (requested by Thunderbird) 18 | "From": {}, 19 | "To": {}, 20 | "Cc": {}, 21 | "Bcc": {}, 22 | "Subject": {}, 23 | "Date": {}, 24 | "Message-Id": {}, 25 | "Priority": {}, 26 | "X-Priority": {}, 27 | "References": {}, 28 | "Newsgroups": {}, 29 | "In-Reply-To": {}, 30 | "Content-Type": {}, 31 | "Reply-To": {}, 32 | "Importance": {}, 33 | "List-Post": {}, 34 | 35 | // Requested by Apple Mail 36 | "X-Uniform-Type-Identifier": {}, 37 | "X-Universally-Unique-Identifier": {}, 38 | 39 | // Misc fields I think clients could be interested in. 40 | "Sender": {}, 41 | "Return-Path": {}, 42 | "Delivered-To": {}, 43 | } 44 | 45 | func (b *Backend) buildFetchStmt(items []imap.FetchItem) (stmt, cacheKey string, err error) { 46 | colNames := make(map[string]struct{}, len(items)+1) 47 | needFlags := false 48 | 49 | colNames["msgs.msgId"] = struct{}{} 50 | 51 | for _, item := range items { 52 | switch item { 53 | case imap.FetchInternalDate: 54 | colNames["date"] = struct{}{} 55 | case imap.FetchRFC822Size: 56 | colNames["bodyLen"] = struct{}{} 57 | case imap.FetchUid: 58 | case imap.FetchEnvelope: 59 | colNames["cachedHeader"] = struct{}{} 60 | case imap.FetchFlags: 61 | needFlags = true 62 | case imap.FetchBody, imap.FetchBodyStructure: 63 | colNames["bodyStructure"] = struct{}{} 64 | default: 65 | _, part, err := getNeededPart(item) 66 | if err != nil { 67 | return "", "", err 68 | } 69 | 70 | switch part { 71 | case needCachedHeader: 72 | colNames["cachedHeader"] = struct{}{} 73 | case needHeader, needFullBody: 74 | colNames["extBodyKey"] = struct{}{} 75 | colNames["compressAlgo"] = struct{}{} 76 | } 77 | } 78 | } 79 | 80 | cols := make([]string, 0, len(colNames)+1) 81 | for col := range colNames { 82 | cols = append(cols, col) 83 | } 84 | extraParams := "" 85 | if needFlags { 86 | extraParams = flagsMidBlock 87 | cols = append(cols, b.db.aggrValuesSet("flag", "{")+" AS flags") 88 | } 89 | 90 | sort.Strings(cols) 91 | 92 | columns := strings.Join(cols, ", ") 93 | return `SELECT ` + columns + ` 94 | FROM msgs 95 | ` + extraParams + ` 96 | WHERE msgs.mboxId = ? AND msgs.msgId BETWEEN ? AND ? 97 | GROUP BY msgs.mboxId, msgs.msgId`, columns, nil 98 | } 99 | 100 | func (b *Backend) getFetchStmt(items []imap.FetchItem) (*sql.Stmt, error) { 101 | str, key, err := b.buildFetchStmt(items) 102 | if err != nil { 103 | return nil, err 104 | } 105 | 106 | b.fetchStmtsLck.RLock() 107 | stmt := b.fetchStmtsCache[key] 108 | b.fetchStmtsLck.RUnlock() 109 | if stmt != nil { 110 | return stmt, nil 111 | } 112 | 113 | stmt, err = b.db.Prepare(str) 114 | if err != nil { 115 | return nil, err 116 | } 117 | 118 | b.fetchStmtsLck.Lock() 119 | b.fetchStmtsCache[key] = stmt 120 | b.fetchStmtsLck.Unlock() 121 | return stmt, nil 122 | } 123 | 124 | type neededPart int 125 | 126 | const ( 127 | needCachedHeader neededPart = iota 128 | needHeader 129 | needFullBody 130 | ) 131 | 132 | func getNeededPart(item imap.FetchItem) (*imap.BodySectionName, neededPart, error) { 133 | var sect *imap.BodySectionName 134 | sect, err := imap.ParseBodySectionName(item) 135 | if err != nil { 136 | return nil, -1, err 137 | } 138 | 139 | onlyHeader := false 140 | onlyCached := false 141 | switch sect.Specifier { 142 | case imap.MIMESpecifier, imap.HeaderSpecifier: 143 | onlyHeader = len(sect.Path) == 0 144 | if sect.Fields != nil && !sect.NotFields && onlyHeader { 145 | onlyCached = true 146 | for _, field := range sect.Fields { 147 | cKey := nettextproto.CanonicalMIMEHeaderKey(field) 148 | if _, ok := cachedHeaderFields[cKey]; !ok { 149 | onlyCached = false 150 | } 151 | } 152 | } 153 | } 154 | 155 | if onlyCached && onlyHeader { 156 | return sect, needCachedHeader, nil 157 | } 158 | if !onlyHeader { 159 | return sect, needFullBody, nil 160 | } 161 | return sect, needHeader, nil 162 | } 163 | -------------------------------------------------------------------------------- /sql_flags.go: -------------------------------------------------------------------------------- 1 | package imapsql 2 | 3 | import ( 4 | "database/sql" 5 | ) 6 | 7 | func (b *Backend) buildFlagsAddStmt(flagsCount int) string { 8 | return ` 9 | INSERT INTO flags 10 | SELECT mboxId, msgId, column1 AS flag 11 | FROM msgs 12 | CROSS JOIN (` + b.db.valuesSubquery(flagsCount) + `) flagset 13 | WHERE mboxId = ? AND msgId BETWEEN ? AND ? 14 | ON CONFLICT DO NOTHING` 15 | } 16 | 17 | func (m *Mailbox) makeFlagsAddStmtArgs(flags []string, start, stop uint32) (params []interface{}) { 18 | params = make([]interface{}, 0, 3+len(flags)) 19 | for _, flag := range flags { 20 | params = append(params, flag) 21 | } 22 | 23 | params = append(params, m.id, start, stop) 24 | return 25 | } 26 | 27 | func (b *Backend) getFlagsAddStmt(flagsCount int) (*sql.Stmt, error) { 28 | str := b.buildFlagsAddStmt(flagsCount) 29 | b.addFlagsStmtsLck.RLock() 30 | stmt := b.addFlagsStmtsCache[str] 31 | b.addFlagsStmtsLck.RUnlock() 32 | if stmt != nil { 33 | return stmt, nil 34 | } 35 | 36 | stmt, err := b.db.Prepare(str) 37 | if err != nil { 38 | return nil, err 39 | } 40 | 41 | b.addFlagsStmtsLck.Lock() 42 | b.addFlagsStmtsCache[str] = stmt 43 | b.addFlagsStmtsLck.Unlock() 44 | return stmt, nil 45 | } 46 | 47 | func (b *Backend) buildFlagsRemStmt(flagsCount int) string { 48 | return ` 49 | DELETE FROM flags 50 | WHERE mboxId = ? 51 | AND msgId BETWEEN ? AND ? 52 | AND flag IN (` + b.db.valuesSubquery(flagsCount) + `)` 53 | } 54 | 55 | func (b *Backend) getFlagsRemStmt(flagsCount int) (*sql.Stmt, error) { 56 | str := b.buildFlagsRemStmt(flagsCount) 57 | b.remFlagsStmtsLck.RLock() 58 | stmt := b.remFlagsStmtsCache[str] 59 | b.remFlagsStmtsLck.RUnlock() 60 | if stmt != nil { 61 | return stmt, nil 62 | } 63 | 64 | stmt, err := b.db.Prepare(str) 65 | if err != nil { 66 | return nil, err 67 | } 68 | 69 | b.remFlagsStmtsLck.Lock() 70 | b.remFlagsStmtsCache[str] = stmt 71 | b.remFlagsStmtsLck.Unlock() 72 | return stmt, nil 73 | } 74 | 75 | func (m *Mailbox) makeFlagsRemStmtArgs(flags []string, start, stop uint32) []interface{} { 76 | params := make([]interface{}, 0, 3+len(flags)) 77 | params = append(params, m.id, start, stop) 78 | for _, flag := range flags { 79 | params = append(params, flag) 80 | } 81 | return params 82 | } 83 | -------------------------------------------------------------------------------- /sql_search.go: -------------------------------------------------------------------------------- 1 | package imapsql 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "strconv" 7 | ) 8 | 9 | func buildSearchStmt(withFlags, withoutFlags []string) string { 10 | var stmt string 11 | stmt += ` 12 | SELECT DISTINCT msgs.msgId 13 | FROM msgs 14 | LEFT JOIN flags ON msgs.msgId = flags.msgid 15 | WHERE msgs.mboxId = ? 16 | ` 17 | 18 | if len(withFlags) != 0 { 19 | if len(withFlags) == 1 { 20 | stmt += `AND flags.flag = ? ` 21 | } else { 22 | stmt += `AND flags.flag IN (` 23 | for i := range withFlags { 24 | stmt += `?` 25 | if i != len(withFlags)-1 { 26 | stmt += `, ` 27 | } 28 | } 29 | stmt += `)` 30 | } 31 | } 32 | if len(withoutFlags) != 0 { 33 | stmt += `AND msgs.msgId NOT IN (` + buildSearchStmt(withoutFlags, nil) + `)` 34 | } 35 | if len(withFlags) > 1 { 36 | stmt += `GROUP BY flags.msgId HAVING COUNT(*) = ` + strconv.Itoa(len(withFlags)) 37 | } 38 | 39 | return stmt 40 | } 41 | 42 | func (m *Mailbox) getFlagSearchStmt(withFlags, withoutFlags []string) (*sql.Stmt, error) { 43 | cacheKey := fmt.Sprint(len(withFlags), ":", len(withoutFlags)) 44 | m.parent.flagsSearchStmtsLck.RLock() 45 | stmt := m.parent.flagsSearchStmtsCache[cacheKey] 46 | m.parent.flagsSearchStmtsLck.RUnlock() 47 | if stmt != nil { 48 | return stmt, nil 49 | } 50 | 51 | stmtStr := buildSearchStmt(withFlags, withoutFlags) 52 | stmt, err := m.parent.db.Prepare(stmtStr) 53 | if err != nil { 54 | return nil, err 55 | } 56 | if len(withFlags) < 3 && len(withoutFlags) < 3 { 57 | m.parent.flagsSearchStmtsLck.Lock() 58 | m.parent.flagsSearchStmtsCache[cacheKey] = stmt 59 | m.parent.flagsSearchStmtsLck.Unlock() 60 | } 61 | 62 | return stmt, nil 63 | } 64 | 65 | func (m *Mailbox) buildFlagSearchQueryArgs(withFlags, withoutFlags []string) []interface{} { 66 | queryArgs := make([]interface{}, 0, 2+len(withFlags)+1+len(withoutFlags)) 67 | queryArgs = append(queryArgs, m.id) 68 | for _, flag := range withFlags { 69 | queryArgs = append(queryArgs, flag) 70 | } 71 | if len(withoutFlags) != 0 { 72 | queryArgs = append(queryArgs, m.id) 73 | for _, flag := range withoutFlags { 74 | queryArgs = append(queryArgs, flag) 75 | } 76 | } 77 | return queryArgs 78 | } 79 | -------------------------------------------------------------------------------- /user.go: -------------------------------------------------------------------------------- 1 | package imapsql 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | "strings" 7 | "time" 8 | 9 | "github.com/emersion/go-imap" 10 | "github.com/emersion/go-imap/backend" 11 | namespace "github.com/foxcpp/go-imap-namespace" 12 | ) 13 | 14 | const MailboxPathSep = "." 15 | 16 | type User struct { 17 | id uint64 18 | username string 19 | inboxId uint64 20 | parent *Backend 21 | } 22 | 23 | func (u *User) Username() string { 24 | return u.username 25 | } 26 | 27 | func (u *User) ID() uint64 { 28 | return u.id 29 | } 30 | 31 | func (u *User) ListMailboxes(subscribed bool) ([]imap.MailboxInfo, error) { 32 | var ( 33 | rows *sql.Rows 34 | err error 35 | ) 36 | if subscribed { 37 | rows, err = u.parent.listSubbedMboxes.Query(u.id) 38 | } else { 39 | rows, err = u.parent.listMboxes.Query(u.id) 40 | } 41 | if err != nil { 42 | u.parent.logUserErr(u, err, "ListMailboxes", subscribed) 43 | return nil, wrapErr(err, "ListMailboxes") 44 | } 45 | defer rows.Close() 46 | 47 | var res []imap.MailboxInfo 48 | for rows.Next() { 49 | info := imap.MailboxInfo{ 50 | Attributes: nil, 51 | Delimiter: MailboxPathSep, 52 | } 53 | 54 | var id uint64 55 | if err := rows.Scan(&id, &info.Name); err != nil { 56 | u.parent.logUserErr(u, err, "ListMailboxes", subscribed) 57 | return nil, wrapErr(err, "ListMailboxes") 58 | } 59 | 60 | res = append(res, info) 61 | } 62 | if err := rows.Err(); err != nil { 63 | u.parent.logUserErr(u, err, "ListMailboxes", subscribed) 64 | return res, wrapErr(rows.Err(), "ListMailboxes") 65 | } 66 | 67 | for i, info := range res { 68 | row := u.parent.getMboxAttrs.QueryRow(u.id, info.Name) 69 | var mark int 70 | var specialUse sql.NullString 71 | if err := row.Scan(&mark, &specialUse); err != nil { 72 | u.parent.logUserErr(u, err, "ListMailboxes (mbox attrs)") 73 | continue 74 | } 75 | if mark == 1 { 76 | info.Attributes = []string{imap.MarkedAttr} 77 | } 78 | if specialUse.Valid { 79 | info.Attributes = []string{specialUse.String} 80 | } 81 | 82 | row = u.parent.hasChildren.QueryRow(info.Name+MailboxPathSep+"%", u.id) 83 | childrenCount := 0 84 | if err := row.Scan(&childrenCount); err != nil { 85 | u.parent.logUserErr(u, err, "ListMailboxes (children count)") 86 | continue 87 | } 88 | if childrenCount != 0 { 89 | info.Attributes = append(info.Attributes, imap.HasChildrenAttr) 90 | } else { 91 | info.Attributes = append(info.Attributes, imap.HasNoChildrenAttr) 92 | } 93 | 94 | res[i] = info 95 | } 96 | 97 | return res, nil 98 | } 99 | 100 | func (u *User) GetMailbox(name string, readOnly bool, conn backend.Conn) (*imap.MailboxStatus, backend.Mailbox, error) { 101 | var mbox *Mailbox 102 | 103 | if strings.EqualFold(name, "INBOX") { 104 | mbox = &Mailbox{user: *u, id: u.inboxId, name: name, parent: u.parent} 105 | } else { 106 | row := u.parent.mboxId.QueryRow(u.id, name) 107 | id := uint64(0) 108 | if err := row.Scan(&id); err != nil { 109 | if err == sql.ErrNoRows { 110 | return nil, nil, backend.ErrNoSuchMailbox 111 | } 112 | u.parent.logUserErr(u, err, "GetMailbox", name) 113 | return nil, nil, wrapErrf(err, "GetMailbox %s", name) 114 | } 115 | mbox = &Mailbox{user: *u, id: id, name: name, parent: u.parent} 116 | } 117 | mbox.readOnly = readOnly 118 | 119 | if conn == nil { 120 | uids, recent, err := mbox.readUids() 121 | if err != nil { 122 | u.parent.logUserErr(u, err, "GetMailbox", name) 123 | return nil, nil, wrapErrf(err, "GetMailbox %s", name) 124 | } 125 | 126 | mbox.handle = u.parent.mngr.ManagementHandle(mbox.id, uids, recent) 127 | return nil, mbox, nil 128 | } 129 | 130 | mbox.conn = conn 131 | uids, recent, status, err := mbox.initSelected(!readOnly) 132 | if err != nil { 133 | u.parent.logUserErr(u, err, "GetMailbox", name) 134 | return nil, nil, wrapErrf(err, "GetMailbox %s", name) 135 | } 136 | 137 | handle, err := u.parent.mngr.Mailbox(mbox.id, mbox, uids, recent) 138 | if err != nil { 139 | u.parent.logUserErr(u, err, "GetMailbox handle", name) 140 | return nil, nil, wrapErrf(err, "GetMailbox %s (get handle)", name) 141 | } 142 | mbox.handle = handle 143 | 144 | return status, mbox, nil 145 | } 146 | 147 | func (u *User) CreateMessageLimit() *uint32 { 148 | res := sql.NullInt64{} 149 | row := u.parent.userMsgSizeLimit.QueryRow(u.id) 150 | if err := row.Scan(&res); err != nil { 151 | // Oops! 152 | return new(uint32) 153 | } 154 | 155 | if !res.Valid { 156 | return nil 157 | } else { 158 | val := uint32(res.Int64) 159 | return &val 160 | } 161 | } 162 | 163 | func (u *User) SetMessageLimit(val *uint32) error { 164 | _, err := u.parent.setUserMsgSizeLimit.Exec(val, u.id) 165 | return err 166 | } 167 | 168 | func (u *User) CreateMailbox(name string) error { 169 | tx, err := u.parent.db.Begin(false) 170 | if err != nil { 171 | u.parent.logUserErr(u, err, "CreateMailbox (tx start)", name) 172 | return wrapErrf(err, "CreateMailbox %s", name) 173 | } 174 | defer tx.Rollback() //nolint:errcheck 175 | 176 | if err := u.createParentDirs(tx, name); err != nil { 177 | u.parent.logUserErr(u, err, "CreateMailbox (parents)", name) 178 | return wrapErrf(err, "CreateMailbox (parents) %s", name) 179 | } 180 | 181 | if _, err := tx.Stmt(u.parent.createMbox).Exec(u.id, name, u.parent.prng.Uint32(), nil); err != nil { 182 | if isForeignKeyErr(err) { 183 | return backend.ErrMailboxAlreadyExists 184 | } 185 | u.parent.logUserErr(u, err, "CreateMailbox", name) 186 | return wrapErrf(err, "CreateMailbox %s", name) 187 | } 188 | 189 | err = tx.Commit() 190 | u.parent.logUserErr(u, err, "CreateMailbox (tx commit)", name) 191 | return wrapErrf(err, "CreateMailbox (tx commit) %s", name) 192 | } 193 | 194 | var ErrUnsupportedSpecialAttr = errors.New("imap: special attribute is not supported") 195 | 196 | // CreateMailboxSpecial creates a mailbox with SPECIAL-USE attribute set. 197 | func (u *User) CreateMailboxSpecial(name, specialUseAttr string) error { 198 | switch specialUseAttr { 199 | case imap.AllAttr, imap.FlaggedAttr: 200 | return ErrUnsupportedSpecialAttr 201 | case imap.ArchiveAttr, imap.DraftsAttr, imap.JunkAttr, imap.SentAttr, imap.TrashAttr: 202 | default: 203 | return ErrUnsupportedSpecialAttr 204 | } 205 | 206 | tx, err := u.parent.db.Begin(false) 207 | if err != nil { 208 | return wrapErrf(err, "CreateMailboxSpecial %s", name) 209 | } 210 | defer tx.Rollback() //nolint:errcheck 211 | 212 | if err := u.createParentDirs(tx, name); err != nil { 213 | return wrapErrf(err, "CreateMailboxSpecial (parents) %s", name) 214 | } 215 | 216 | if _, err := tx.Stmt(u.parent.createMbox).Exec(u.id, name, u.parent.prng.Uint32(), specialUseAttr); err != nil { 217 | if isForeignKeyErr(err) { 218 | return backend.ErrMailboxAlreadyExists 219 | } 220 | return wrapErrf(err, "CreateMailboxSpecial %s", name) 221 | } 222 | 223 | return wrapErrf(tx.Commit(), "CreateMailbox (tx commit) %s", name) 224 | } 225 | 226 | func (u *User) DeleteMailbox(name string) error { 227 | if strings.ToLower(name) == "inbox" { 228 | return errors.New("DeleteMailbox: can't delete INBOX") 229 | } 230 | 231 | tx, err := u.parent.db.BeginLevel(sql.LevelRepeatableRead, false) 232 | if err != nil { 233 | u.parent.logUserErr(u, err, "DeleteMailbox (tx start)", name) 234 | return wrapErrf(err, "DeleteMailbox %s", name) 235 | } 236 | defer tx.Rollback() 237 | 238 | if _, err := tx.Stmt(u.parent.decreaseRefForMbox).Exec(u.id, name); err != nil { 239 | u.parent.logUserErr(u, err, "DeleteMailbox (decrease ref)", name) 240 | return wrapErrf(err, "DeleteMailbox %s", name) 241 | } 242 | 243 | rows, err := tx.Stmt(u.parent.zeroRefUser).Query(u.id) 244 | if err != nil { 245 | u.parent.logUserErr(u, err, "DeleteMailbox (zero ref user)", name) 246 | return wrapErrf(err, "DeleteMailbox %s", name) 247 | } 248 | defer rows.Close() 249 | 250 | keys := make([]string, 0, 16) 251 | for rows.Next() { 252 | var extKey string 253 | if err := rows.Scan(&extKey); err != nil { 254 | u.parent.logUserErr(u, err, "DeleteMailbox (extkeys scan)", name) 255 | return wrapErrf(err, "DeleteMailbox %s", name) 256 | } 257 | keys = append(keys, extKey) 258 | 259 | } 260 | 261 | if err := u.parent.extStore.Delete(keys); err != nil { 262 | u.parent.logUserErr(u, err, "DeleteMailbox (extstore delete)", name) 263 | return wrapErrf(err, "DeleteMailbox %s", name) 264 | } 265 | 266 | // TODO: Grab mboxId along the way on PostgreSQL? 267 | stats, err := tx.Stmt(u.parent.deleteMbox).Exec(u.id, name) 268 | if err != nil { 269 | u.parent.logUserErr(u, err, "DeleteMailbox (delete mbox)", name) 270 | return wrapErrf(err, "DeleteMailbox %s", name) 271 | } 272 | affected, err := stats.RowsAffected() 273 | if err != nil { 274 | u.parent.logUserErr(u, err, "DeleteMailbox (stats)", name) 275 | return wrapErrf(err, "DeleteMailbox %s", name) 276 | } 277 | if affected == 0 { 278 | return backend.ErrNoSuchMailbox 279 | } 280 | 281 | if _, err := tx.Stmt(u.parent.deleteZeroRef).Exec(u.id); err != nil { 282 | u.parent.logUserErr(u, err, "DeleteMailbox (delete zero ref)", name) 283 | return wrapErrf(err, "DeleteMailbox %s", name) 284 | } 285 | 286 | err = tx.Commit() 287 | u.parent.logUserErr(u, err, "DeleteMailbox (tx commit)", name) 288 | return err 289 | } 290 | 291 | func (u *User) RenameMailbox(existingName, newName string) error { 292 | tx, err := u.parent.db.Begin(false) 293 | if err != nil { 294 | u.parent.logUserErr(u, err, "RenameMailbox (tx start)", existingName, newName) 295 | return wrapErrf(err, "RenameMailbox %s, %s", existingName, newName) 296 | } 297 | defer tx.Rollback() //nolint:errcheck 298 | 299 | if err := u.createParentDirs(tx, newName); err != nil { 300 | u.parent.logUserErr(u, err, "RenameMailbox (create parents)", existingName, newName) 301 | return wrapErrf(err, "RenameMailbox %s, %s", existingName, newName) 302 | } 303 | 304 | if _, err := tx.Stmt(u.parent.renameMbox).Exec(newName, u.id, existingName); err != nil { 305 | u.parent.logUserErr(u, err, "RenameMailbox", existingName, newName) 306 | return wrapErrf(err, "RenameMailbox %s, %s", existingName, newName) 307 | } 308 | 309 | // TODO: Check if it possible to merge these queries. 310 | existingPattern := existingName + MailboxPathSep + "%" 311 | newPrefix := newName + MailboxPathSep 312 | existingPrefixLen := len(existingName + MailboxPathSep) 313 | if _, err := tx.Stmt(u.parent.renameMboxChilds).Exec(newPrefix, existingPrefixLen, existingPattern, u.id); err != nil { 314 | u.parent.logUserErr(u, err, "RenameMailbox (childs)", existingName, newName) 315 | return wrapErrf(err, "RenameMailbox (childs) %s, %s", existingName, newName) 316 | } 317 | 318 | if strings.EqualFold(existingName, "INBOX") { 319 | if _, err := tx.Stmt(u.parent.createMbox).Exec(u.id, existingName, u.parent.prng.Uint32(), nil); err != nil { 320 | u.parent.logUserErr(u, err, "RenameMailbox (create inbox)", existingName, newName) 321 | return wrapErrf(err, "RenameMailbox %s, %s", existingName, newName) 322 | } 323 | 324 | // TODO: Cut a query here by using RETURNING on PostgreSQL 325 | var inboxId uint64 326 | if err = tx.Stmt(u.parent.mboxId).QueryRow(u.id, "INBOX").Scan(&inboxId); err != nil { 327 | u.parent.logUserErr(u, err, "RenameMailbox (query mboxid id)", existingName, newName) 328 | return wrapErrf(err, "RenameMailbox %s, %s", existingName, newName) 329 | } 330 | if _, err := tx.Stmt(u.parent.setInboxId).Exec(inboxId, u.id); err != nil { 331 | u.parent.logUserErr(u, err, "RenameMailbox (set inbox id)", existingName, newName) 332 | return wrapErrf(err, "RenameMailbox %s, %s", existingName, newName) 333 | } 334 | } 335 | 336 | err = tx.Commit() 337 | u.parent.logUserErr(u, err, "RenameMailbox (tx commit)", existingName, newName) 338 | return wrapErrf(err, "RenameMailbox %s, %s", existingName, newName) 339 | } 340 | 341 | func (u *User) Logout() error { 342 | return nil 343 | } 344 | 345 | func (u *User) createParentDirs(tx *sql.Tx, name string) error { 346 | parts := strings.Split(name, MailboxPathSep) 347 | curDir := "" 348 | for i, part := range parts[:len(parts)-1] { 349 | if i != 0 { 350 | curDir += MailboxPathSep 351 | } 352 | curDir += part 353 | 354 | if _, err := tx.Stmt(u.parent.createMboxExistsOk).Exec(u.id, curDir, u.parent.prng.Uint32()); err != nil { 355 | return err 356 | } 357 | } 358 | return nil 359 | } 360 | 361 | func (u *User) Namespaces() (personal, other, shared []namespace.Namespace, err error) { 362 | return []namespace.Namespace{ 363 | { 364 | Prefix: "", 365 | Delimiter: MailboxPathSep, 366 | }, 367 | }, nil, nil, nil 368 | } 369 | 370 | func (u *User) CreateMessage(mboxName string, flags []string, date time.Time, fullBody imap.Literal, _ backend.Mailbox) error { 371 | _, box, err := u.GetMailbox(mboxName, false, nil) 372 | if err != nil { 373 | return err 374 | } 375 | defer box.Close() 376 | 377 | return box.(*Mailbox).CreateMessage(flags, date, fullBody) 378 | } 379 | 380 | func (u *User) SetSubscribed(mboxName string, sub bool) error { 381 | i := 0 382 | if sub { 383 | i = 1 384 | } 385 | 386 | _, err := u.parent.setSubbed.Exec(i, u.id, mboxName) 387 | return err 388 | } 389 | 390 | func (u *User) Status(mbox string, items []imap.StatusItem) (*imap.MailboxStatus, error) { 391 | tx, err := u.parent.db.BeginLevel(sql.LevelReadCommitted, true) 392 | if err != nil { 393 | return nil, err 394 | } 395 | defer tx.Rollback() 396 | 397 | var mboxId uint64 398 | if err := tx.Stmt(u.parent.mboxId).QueryRow(u.id, mbox).Scan(&mboxId); err != nil { 399 | if err == sql.ErrNoRows { 400 | return nil, backend.ErrNoSuchMailbox 401 | } 402 | return nil, err 403 | } 404 | 405 | status := imap.NewMailboxStatus(mbox, items) 406 | for _, item := range items { 407 | switch item { 408 | case imap.StatusMessages: 409 | err := tx.Stmt(u.parent.msgsCount).QueryRow(mboxId).Scan(&status.Messages) 410 | if err != nil { 411 | u.parent.logUserErr(u, err, "Status: messages scan") 412 | return nil, errors.New("I/O error") 413 | } 414 | case imap.StatusRecent: 415 | err := tx.Stmt(u.parent.recentCount).QueryRow(mboxId).Scan(&status.Recent) 416 | if err != nil { 417 | u.parent.logUserErr(u, err, "Status: recent scan") 418 | return nil, errors.New("I/O error") 419 | } 420 | case imap.StatusUidNext: 421 | err := tx.Stmt(u.parent.uidNext).QueryRow(mboxId).Scan(&status.UidNext) 422 | if err != nil { 423 | u.parent.logUserErr(u, err, "Status: uidNext scan") 424 | return nil, errors.New("I/O error") 425 | } 426 | case imap.StatusUidValidity: 427 | err := tx.Stmt(u.parent.uidValidity).QueryRow(mboxId).Scan(&status.UidValidity) 428 | if err != nil { 429 | u.parent.logUserErr(u, err, "Status: uidValidity scan") 430 | return nil, errors.New("I/O error") 431 | } 432 | case imap.StatusUnseen: 433 | err := tx.Stmt(u.parent.unseenCount).QueryRow(mboxId).Scan(&status.Unseen) 434 | if err != nil { 435 | u.parent.logUserErr(u, err, "Status: unseen scan") 436 | delete(status.Items, imap.StatusUnseen) 437 | continue 438 | } 439 | case imap.StatusAppendLimit: 440 | var res sql.NullInt64 441 | row := tx.Stmt(u.parent.mboxMsgSizeLimit).QueryRow(mboxId) 442 | if err := row.Scan(&res); err != nil { 443 | u.parent.logUserErr(u, err, "Status: appendLimit scan") 444 | continue 445 | } 446 | if res.Valid { 447 | status.AppendLimit = uint32(res.Int64) 448 | } 449 | } 450 | } 451 | 452 | return status, nil 453 | } 454 | -------------------------------------------------------------------------------- /user_test.go: -------------------------------------------------------------------------------- 1 | package imapsql 2 | 3 | import ( 4 | "testing" 5 | 6 | "gotest.tools/assert" 7 | ) 8 | 9 | func TestUserCaseInsensitivity(t *testing.T) { 10 | b := initTestBackend().(*Backend) 11 | defer cleanBackend(b) 12 | 13 | assert.NilError(t, b.CreateUser("foXcpp")) 14 | _, err := b.Login(nil, "foXCpp", "") 15 | assert.NilError(t, err, "b.Login") 16 | u1, err := b.GetUser("Foxcpp") 17 | assert.NilError(t, err, "b.GetUser") 18 | u2, err := b.GetOrCreateUser("FOXcpp") 19 | assert.NilError(t, err, "b.GetOrCreateUser") 20 | 21 | assert.NilError(t, u1.CreateMailbox("BOX")) 22 | _, mbox, err := u2.GetMailbox("BOX", true, &noopConn{}) 23 | assert.NilError(t, err, "u2.GetMailbox") 24 | defer mbox.Close() 25 | } 26 | 27 | func TestInboxCreation(t *testing.T) { 28 | b := initTestBackend().(*Backend) 29 | defer cleanBackend(b) 30 | 31 | assert.NilError(t, b.CreateUser("foxcpp")) 32 | 33 | u, err := b.GetUser("foxcpp") 34 | assert.NilError(t, err, "b.GetUser") 35 | 36 | _, mbox, err := u.GetMailbox("INBOX", true, &noopConn{}) 37 | assert.NilError(t, err, "u.GetMailbox") 38 | defer mbox.Close() 39 | } 40 | --------------------------------------------------------------------------------