Skip to main content

max / goingson

Email credential split, plugin runtime updates, new themes, OTA and sync fixes Migration 033: email credential split. JMAP/IMAP client improvements. Plugin runtime API and registry updates. 7 new themes added. Sync service and OAuth credential hardening.
Co-Authored-By
Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-18 20:51 UTC
Commit: 8ac7c486832734d1580df95caa0361a7b8224777
Parent: b93a8f0
31 files changed, +729 insertions, -374 deletions
M Cargo.lock +70 -175
@@ -414,22 +414,13 @@
414 414 "generic-array",
415 415 ]
416 416
417 - [[package]]
418 - name = "block2"
419 - version = "0.5.1"
420 - source = "registry+https://github.com/rust-lang/crates.io-index"
421 - checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f"
422 - dependencies = [
423 - "objc2 0.5.2",
424 - ]
425 -
426 417 [[package]]
427 418 name = "block2"
428 419 version = "0.6.2"
429 420 source = "registry+https://github.com/rust-lang/crates.io-index"
430 421 checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
431 422 dependencies = [
432 - "objc2 0.6.3",
423 + "objc2",
433 424 ]
434 425
435 426 [[package]]
@@ -1082,9 +1073,9 @@
1082 1073 checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
1083 1074 dependencies = [
1084 1075 "bitflags 2.10.0",
1085 - "block2 0.6.2",
1076 + "block2",
1086 1077 "libc",
1087 - "objc2 0.6.3",
1078 + "objc2",
1088 1079 ]
1089 1080
1090 1081 [[package]]
@@ -1867,9 +1858,6 @@
1867 1858 "mailparse",
1868 1859 "notify",
1869 1860 "notify-debouncer-mini",
1870 - "objc2 0.5.2",
1871 - "objc2-app-kit 0.2.2",
1872 - "objc2-foundation 0.2.2",
1873 1861 "open",
1874 1862 "rand 0.8.5",
1875 1863 "reqwest 0.12.28",
@@ -2808,8 +2796,8 @@
2808 2796 checksum = "65fd3f75411f4725061682ed91f131946e912859d0044d39c4ec0aac818d7621"
2809 2797 dependencies = [
2810 2798 "cc",
2811 - "objc2 0.6.3",
2812 - "objc2-foundation 0.3.2",
2799 + "objc2",
2800 + "objc2-foundation",
2813 2801 "time",
2814 2802 ]
2815 2803
@@ -2988,10 +2976,10 @@
2988 2976 "dpi",
2989 2977 "gtk",
2990 2978 "keyboard-types",
2991 - "objc2 0.6.3",
2992 - "objc2-app-kit 0.3.2",
2979 + "objc2",
2980 + "objc2-app-kit",
2993 2981 "objc2-core-foundation",
2994 - "objc2-foundation 0.3.2",
2982 + "objc2-foundation",
2995 2983 "once_cell",
2996 2984 "png 0.17.16",
2997 2985 "serde",
@@ -3222,22 +3210,6 @@
3222 3210 "syn 2.0.114",
3223 3211 ]
3224 3212
3225 - [[package]]
3226 - name = "objc-sys"
3227 - version = "0.3.5"
3228 - source = "registry+https://github.com/rust-lang/crates.io-index"
3229 - checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310"
3230 -
3231 - [[package]]
3232 - name = "objc2"
3233 - version = "0.5.2"
3234 - source = "registry+https://github.com/rust-lang/crates.io-index"
3235 - checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804"
3236 - dependencies = [
3237 - "objc-sys",
3238 - "objc2-encode",
3239 - ]
3240 -
3241 3213 [[package]]
3242 3214 name = "objc2"
3243 3215 version = "0.6.3"
@@ -3248,22 +3220,6 @@
3248 3220 "objc2-exception-helper",
3249 3221 ]
3250 3222
3251 - [[package]]
3252 - name = "objc2-app-kit"
3253 - version = "0.2.2"
3254 - source = "registry+https://github.com/rust-lang/crates.io-index"
3255 - checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff"
3256 - dependencies = [
3257 - "bitflags 2.10.0",
3258 - "block2 0.5.1",
3259 - "libc",
3260 - "objc2 0.5.2",
3261 - "objc2-core-data 0.2.2",
3262 - "objc2-core-image 0.2.2",
3263 - "objc2-foundation 0.2.2",
3264 - "objc2-quartz-core 0.2.2",
3265 - ]
3266 -
3267 3223 [[package]]
3268 3224 name = "objc2-app-kit"
3269 3225 version = "0.3.2"
@@ -3271,18 +3227,18 @@
3271 3227 checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
3272 3228 dependencies = [
3273 3229 "bitflags 2.10.0",
3274 - "block2 0.6.2",
3230 + "block2",
3275 3231 "libc",
3276 - "objc2 0.6.3",
3232 + "objc2",
3277 3233 "objc2-cloud-kit",
3278 - "objc2-core-data 0.3.2",
3234 + "objc2-core-data",
3279 3235 "objc2-core-foundation",
3280 3236 "objc2-core-graphics",
3281 - "objc2-core-image 0.3.2",
3237 + "objc2-core-image",
3282 3238 "objc2-core-text",
3283 3239 "objc2-core-video",
3284 - "objc2-foundation 0.3.2",
3285 - "objc2-quartz-core 0.3.2",
3240 + "objc2-foundation",
3241 + "objc2-quartz-core",
3286 3242 ]
3287 3243
3288 3244 [[package]]
@@ -3292,20 +3248,8 @@
3292 3248 checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
3293 3249 dependencies = [
3294 3250 "bitflags 2.10.0",
3295 - "objc2 0.6.3",
3296 - "objc2-foundation 0.3.2",
3297 - ]
3298 -
3299 - [[package]]
3300 - name = "objc2-core-data"
3301 - version = "0.2.2"
3302 - source = "registry+https://github.com/rust-lang/crates.io-index"
3303 - checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef"
3304 - dependencies = [
3305 - "bitflags 2.10.0",
3306 - "block2 0.5.1",
3307 - "objc2 0.5.2",
3308 - "objc2-foundation 0.2.2",
3251 + "objc2",
3252 + "objc2-foundation",
3309 3253 ]
3310 3254
3311 3255 [[package]]
@@ -3315,8 +3259,8 @@
3315 3259 checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
3316 3260 dependencies = [
3317 3261 "bitflags 2.10.0",
3318 - "objc2 0.6.3",
3319 - "objc2-foundation 0.3.2",
3262 + "objc2",
3263 + "objc2-foundation",
3320 3264 ]
3321 3265
3322 3266 [[package]]
@@ -3327,7 +3271,7 @@
3327 3271 dependencies = [
3328 3272 "bitflags 2.10.0",
3329 3273 "dispatch2",
3330 - "objc2 0.6.3",
3274 + "objc2",
3331 3275 ]
3332 3276
3333 3277 [[package]]
@@ -3338,31 +3282,19 @@
3338 3282 dependencies = [
3339 3283 "bitflags 2.10.0",
3340 3284 "dispatch2",
3341 - "objc2 0.6.3",
3285 + "objc2",
3342 3286 "objc2-core-foundation",
3343 3287 "objc2-io-surface",
3344 3288 ]
3345 3289
3346 - [[package]]
3347 - name = "objc2-core-image"
3348 - version = "0.2.2"
3349 - source = "registry+https://github.com/rust-lang/crates.io-index"
3350 - checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80"
3351 - dependencies = [
3352 - "block2 0.5.1",
3353 - "objc2 0.5.2",
3354 - "objc2-foundation 0.2.2",
3355 - "objc2-metal",
3356 - ]
3357 -
3358 3290 [[package]]
3359 3291 name = "objc2-core-image"
3360 3292 version = "0.3.2"
3361 3293 source = "registry+https://github.com/rust-lang/crates.io-index"
3362 3294 checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006"
3363 3295 dependencies = [
3364 - "objc2 0.6.3",
3365 - "objc2-foundation 0.3.2",
3296 + "objc2",
3297 + "objc2-foundation",
3366 3298 ]
3367 3299
3368 3300 [[package]]
@@ -3372,7 +3304,7 @@
3372 3304 checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
3373 3305 dependencies = [
3374 3306 "bitflags 2.10.0",
3375 - "objc2 0.6.3",
3307 + "objc2",
3376 3308 "objc2-core-foundation",
3377 3309 "objc2-core-graphics",
3378 3310 ]
@@ -3384,7 +3316,7 @@
3384 3316 checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6"
3385 3317 dependencies = [
3386 3318 "bitflags 2.10.0",
3387 - "objc2 0.6.3",
3319 + "objc2",
3388 3320 "objc2-core-foundation",
3389 3321 "objc2-core-graphics",
3390 3322 "objc2-io-surface",
@@ -3405,18 +3337,6 @@
3405 3337 "cc",
3406 3338 ]
3407 3339
3408 - [[package]]
3409 - name = "objc2-foundation"
3410 - version = "0.2.2"
3411 - source = "registry+https://github.com/rust-lang/crates.io-index"
3412 - checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8"
3413 - dependencies = [
3414 - "bitflags 2.10.0",
3415 - "block2 0.5.1",
3416 - "libc",
3417 - "objc2 0.5.2",
3418 - ]
3419 -
3420 3340 [[package]]
3421 3341 name = "objc2-foundation"
3422 3342 version = "0.3.2"
@@ -3424,9 +3344,9 @@
3424 3344 checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
3425 3345 dependencies = [
3426 3346 "bitflags 2.10.0",
3427 - "block2 0.6.2",
3347 + "block2",
3428 3348 "libc",
3429 - "objc2 0.6.3",
3349 + "objc2",
3430 3350 "objc2-core-foundation",
3431 3351 ]
3432 3352
@@ -3437,7 +3357,7 @@
3437 3357 checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
3438 3358 dependencies = [
3439 3359 "bitflags 2.10.0",
3440 - "objc2 0.6.3",
3360 + "objc2",
3441 3361 "objc2-core-foundation",
3442 3362 ]
3443 3363
@@ -3447,22 +3367,10 @@
3447 3367 source = "registry+https://github.com/rust-lang/crates.io-index"
3448 3368 checksum = "2a1e6550c4caed348956ce3370c9ffeca70bb1dbed4fa96112e7c6170e074586"
3449 3369 dependencies = [
3450 - "objc2 0.6.3",
3370 + "objc2",
3451 3371 "objc2-core-foundation",
3452 3372 ]
3453 3373
3454 - [[package]]
3455 - name = "objc2-metal"
3456 - version = "0.2.2"
3457 - source = "registry+https://github.com/rust-lang/crates.io-index"
3458 - checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"
3459 - dependencies = [
3460 - "bitflags 2.10.0",
3461 - "block2 0.5.1",
3462 - "objc2 0.5.2",
3463 - "objc2-foundation 0.2.2",
3464 - ]
3465 -
3466 3374 [[package]]
3467 3375 name = "objc2-osa-kit"
3468 3376 version = "0.3.2"
@@ -3470,22 +3378,9 @@
3470 3378 checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
3471 3379 dependencies = [
3472 3380 "bitflags 2.10.0",
3473 - "objc2 0.6.3",
3474 - "objc2-app-kit 0.3.2",
3475 - "objc2-foundation 0.3.2",
3476 - ]
3477 -
3478 - [[package]]
3479 - name = "objc2-quartz-core"
3480 - version = "0.2.2"
3481 - source = "registry+https://github.com/rust-lang/crates.io-index"
3482 - checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"
3483 - dependencies = [
3484 - "bitflags 2.10.0",
3485 - "block2 0.5.1",
3486 - "objc2 0.5.2",
3487 - "objc2-foundation 0.2.2",
3488 - "objc2-metal",
3381 + "objc2",
3382 + "objc2-app-kit",
3383 + "objc2-foundation",
3489 3384 ]
3490 3385
3491 3386 [[package]]
@@ -3495,9 +3390,9 @@
3495 3390 checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
3496 3391 dependencies = [
3497 3392 "bitflags 2.10.0",
3498 - "objc2 0.6.3",
3393 + "objc2",
3499 3394 "objc2-core-foundation",
3500 - "objc2-foundation 0.3.2",
3395 + "objc2-foundation",
3501 3396 ]
3502 3397
3503 3398 [[package]]
@@ -3507,7 +3402,7 @@
3507 3402 checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a"
3508 3403 dependencies = [
3509 3404 "bitflags 2.10.0",
3510 - "objc2 0.6.3",
3405 + "objc2",
3511 3406 "objc2-core-foundation",
3512 3407 ]
3513 3408
@@ -3518,9 +3413,9 @@
3518 3413 checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
3519 3414 dependencies = [
3520 3415 "bitflags 2.10.0",
3521 - "objc2 0.6.3",
3416 + "objc2",
3522 3417 "objc2-core-foundation",
3523 - "objc2-foundation 0.3.2",
3418 + "objc2-foundation",
3524 3419 ]
3525 3420
3526 3421 [[package]]
@@ -3530,11 +3425,11 @@
3530 3425 checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f"
3531 3426 dependencies = [
3532 3427 "bitflags 2.10.0",
3533 - "block2 0.6.2",
3534 - "objc2 0.6.3",
3535 - "objc2-app-kit 0.3.2",
3428 + "block2",
3429 + "objc2",
3430 + "objc2-app-kit",
3536 3431 "objc2-core-foundation",
3537 - "objc2-foundation 0.3.2",
3432 + "objc2-foundation",
3538 3433 "objc2-javascript-core",
3539 3434 "objc2-security",
3540 3435 ]
@@ -3657,8 +3552,8 @@
3657 3552 source = "registry+https://github.com/rust-lang/crates.io-index"
3658 3553 checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
3659 3554 dependencies = [
3660 - "objc2 0.6.3",
3661 - "objc2-foundation 0.3.2",
3555 + "objc2",
3556 + "objc2-foundation",
3662 3557 "objc2-osa-kit",
3663 3558 "serde",
3664 3559 "serde_json",
@@ -4466,17 +4361,17 @@
4466 4361 source = "registry+https://github.com/rust-lang/crates.io-index"
4467 4362 checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
4468 4363 dependencies = [
4469 - "block2 0.6.2",
4364 + "block2",
4470 4365 "dispatch2",
4471 4366 "glib-sys",
4472 4367 "gobject-sys",
4473 4368 "gtk-sys",
4474 4369 "js-sys",
4475 4370 "log",
4476 - "objc2 0.6.3",
4477 - "objc2-app-kit 0.3.2",
4371 + "objc2",
4372 + "objc2-app-kit",
4478 4373 "objc2-core-foundation",
4479 - "objc2-foundation 0.3.2",
4374 + "objc2-foundation",
4480 4375 "raw-window-handle",
4481 4376 "wasm-bindgen",
4482 4377 "wasm-bindgen-futures",
@@ -5161,11 +5056,11 @@
5161 5056 "bytemuck",
5162 5057 "js-sys",
5163 5058 "ndk",
5164 - "objc2 0.6.3",
5059 + "objc2",
5165 5060 "objc2-core-foundation",
5166 5061 "objc2-core-graphics",
5167 - "objc2-foundation 0.3.2",
5168 - "objc2-quartz-core 0.3.2",
5062 + "objc2-foundation",
5063 + "objc2-quartz-core",
5169 5064 "raw-window-handle",
5170 5065 "redox_syscall 0.5.18",
5171 5066 "tracing",
@@ -5643,7 +5538,7 @@
5643 5538 checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7"
5644 5539 dependencies = [
5645 5540 "bitflags 2.10.0",
5646 - "block2 0.6.2",
5541 + "block2",
5647 5542 "core-foundation 0.10.1",
5648 5543 "core-graphics",
5649 5544 "crossbeam-channel",
@@ -5660,9 +5555,9 @@
5660 5555 "ndk",
5661 5556 "ndk-context",
5662 5557 "ndk-sys",
5663 - "objc2 0.6.3",
5664 - "objc2-app-kit 0.3.2",
5665 - "objc2-foundation 0.3.2",
5558 + "objc2",
5559 + "objc2-app-kit",
5560 + "objc2-foundation",
5666 5561 "once_cell",
5667 5562 "parking_lot",
5668 5563 "raw-window-handle",
@@ -5727,9 +5622,9 @@
5727 5622 "log",
5728 5623 "mime",
5729 5624 "muda",
5730 - "objc2 0.6.3",
5731 - "objc2-app-kit 0.3.2",
5732 - "objc2-foundation 0.3.2",
5625 + "objc2",
5626 + "objc2-app-kit",
5627 + "objc2-foundation",
5733 5628 "objc2-ui-kit",
5734 5629 "objc2-web-kit",
5735 5630 "percent-encoding",
@@ -5975,7 +5870,7 @@
5975 5870 "gtk",
5976 5871 "http",
5977 5872 "jni",
5978 - "objc2 0.6.3",
5873 + "objc2",
5979 5874 "objc2-ui-kit",
5980 5875 "objc2-web-kit",
5981 5876 "raw-window-handle",
@@ -5999,9 +5894,9 @@
5999 5894 "http",
6000 5895 "jni",
6001 5896 "log",
6002 - "objc2 0.6.3",
6003 - "objc2-app-kit 0.3.2",
6004 - "objc2-foundation 0.3.2",
5897 + "objc2",
5898 + "objc2-app-kit",
5899 + "objc2-foundation",
6005 5900 "once_cell",
6006 5901 "percent-encoding",
6007 5902 "raw-window-handle",
@@ -6161,9 +6056,9 @@
6161 6056
6162 6057 [[package]]
6163 6058 name = "time"
6164 - version = "0.3.46"
6059 + version = "0.3.47"
6165 6060 source = "registry+https://github.com/rust-lang/crates.io-index"
6166 - checksum = "9da98b7d9b7dad93488a84b8248efc35352b0b2657397d4167e7ad67e5d535e5"
6061 + checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
6167 6062 dependencies = [
6168 6063 "deranged",
6169 6064 "itoa",
@@ -6182,9 +6077,9 @@
6182 6077
6183 6078 [[package]]
6184 6079 name = "time-macros"
6185 - version = "0.2.26"
6080 + version = "0.2.27"
6186 6081 source = "registry+https://github.com/rust-lang/crates.io-index"
6187 - checksum = "78cc610bac2dcee56805c99642447d4c5dbde4d01f752ffea0199aee1f601dc4"
6082 + checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
6188 6083 dependencies = [
6189 6084 "num-conv",
6190 6085 "time-core",
@@ -6508,11 +6403,11 @@
6508 6403 "dirs",
6509 6404 "libappindicator",
6510 6405 "muda",
6511 - "objc2 0.6.3",
6512 - "objc2-app-kit 0.3.2",
6406 + "objc2",
6407 + "objc2-app-kit",
6513 6408 "objc2-core-foundation",
6514 6409 "objc2-core-graphics",
6515 - "objc2-foundation 0.3.2",
6410 + "objc2-foundation",
6516 6411 "once_cell",
6517 6412 "png 0.17.16",
6518 6413 "serde",
@@ -7025,10 +6920,10 @@
7025 6920 source = "registry+https://github.com/rust-lang/crates.io-index"
7026 6921 checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c"
7027 6922 dependencies = [
7028 - "objc2 0.6.3",
7029 - "objc2-app-kit 0.3.2",
6923 + "objc2",
6924 + "objc2-app-kit",
7030 6925 "objc2-core-foundation",
7031 - "objc2-foundation 0.3.2",
6926 + "objc2-foundation",
7032 6927 "raw-window-handle",
7033 6928 "windows-sys 0.59.0",
7034 6929 "windows-version",
@@ -7546,7 +7441,7 @@
Lines truncated
@@ -81,12 +81,6 @@
81 81
82 82 # === Desktop-only dependencies (not available on iOS/Android) ===
83 83
84 - # macOS-only: dock icon
85 - [target.'cfg(target_os = "macos")'.dependencies]
86 - objc2 = "0.5"
87 - objc2-app-kit = { version = "0.2", features = ["NSApplication", "NSImage", "NSResponder"] }
88 - objc2-foundation = { version = "0.2", features = ["NSData", "NSThread"] }
89 -
90 84 # Desktop-only: plugins that require desktop window management
91 85 [target.'cfg(not(any(target_os = "ios", target_os = "android")))'.dependencies]
92 86 tauri-plugin-shell = { workspace = true }
@@ -26,26 +26,6 @@
26 26 #[cfg(not(any(target_os = "ios", target_os = "android")))]
27 27 use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
28 28
29 - /// Set the macOS dock icon from embedded PNG data.
30 - /// This is needed in dev mode since the app isn't bundled.
31 - #[cfg(target_os = "macos")]
32 - fn set_dock_icon() {
33 - use objc2::ClassType;
34 - use objc2_app_kit::{NSApplication, NSImage};
35 - use objc2_foundation::{MainThreadMarker, NSData};
36 -
37 - const ICON_DATA: &[u8] = include_bytes!("../icons/icon.png");
38 -
39 - let Some(mtm) = MainThreadMarker::new() else {
40 - return;
41 - };
42 - let app = NSApplication::sharedApplication(mtm);
43 - let data = NSData::with_bytes(ICON_DATA);
44 - if let Some(image) = NSImage::initWithData(NSImage::alloc(), &data) {
45 - unsafe { app.setApplicationIconImage(Some(&image)) };
46 - }
47 - }
48 -
49 29 /// Set up the macOS menu bar tray icon showing "GO" in Reglo.
50 30 #[cfg(not(any(target_os = "ios", target_os = "android")))]
51 31 fn setup_tray(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
@@ -292,10 +272,6 @@
292 272 }
293 273 })
294 274 .setup(|app| {
295 - // Set dock icon (needed for dev mode on macOS)
296 - #[cfg(target_os = "macos")]
297 - set_dock_icon();
298 -
299 275 // Set up menu bar tray icon (desktop only)
300 276 #[cfg(not(any(target_os = "ios", target_os = "android")))]
301 277 {
@@ -89,6 +89,9 @@
89 89 // Ensure desktop user exists (single-user mode)
90 90 ensure_desktop_user_exists(&pool).await?;
91 91
92 + // Migrate LLM API key from database to keychain (one-time)
93 + migrate_llm_api_key_to_keychain(&pool).await;
94 +
92 95 // Create repositories
93 96 let projects = Arc::new(SqliteProjectRepository::new(pool.clone()));
94 97 let tasks = Arc::new(SqliteTaskRepository::new(pool.clone()));
@@ -196,3 +199,36 @@
196 199
197 200 Ok(())
198 201 }
202 +
203 + /// One-time migration: if LLM API key exists in the database, move it to keychain and set DB column to NULL.
204 + #[instrument(skip(pool))]
205 + async fn migrate_llm_api_key_to_keychain(pool: &SqlitePool) {
206 + let user_id = DESKTOP_USER_ID.to_string();
207 +
208 + let row: Option<(Option<String>,)> = sqlx::query_as(
209 + "SELECT api_key FROM llm_settings WHERE user_id = ? AND api_key IS NOT NULL AND api_key != ''"
210 + )
211 + .bind(&user_id)
212 + .fetch_optional(pool)
213 + .await
214 + .unwrap_or(None);
215 +
216 + if let Some((Some(db_key),)) = row {
217 + // Only migrate if keychain doesn't already have a key
218 + if crate::oauth::CredentialStore::get_llm_api_key().is_none() {
219 + match crate::oauth::CredentialStore::store_llm_api_key(&db_key) {
220 + Ok(()) => {
221 + // Clear the key from the database
222 + let _ = sqlx::query("UPDATE llm_settings SET api_key = NULL WHERE user_id = ?")
223 + .bind(&user_id)
224 + .execute(pool)
225 + .await;
226 + info!("Migrated LLM API key from database to keychain");
227 + }
228 + Err(e) => {
229 + warn!("Failed to migrate LLM API key to keychain: {}", e);
230 + }
231 + }
232 + }
233 + }
234 + }
@@ -22,7 +22,7 @@
22 22 use chrono::Utc;
23 23 use goingson_core::CoreError;
24 24 use serde::{Deserialize, Serialize};
25 - use sqlx::SqlitePool;
25 + use sqlx::{SqliteConnection, SqlitePool};
26 26 use synckit_client::{ChangeEntry, ChangeOp, SyncKitClient};
27 27 use tracing::{debug, info, warn};
28 28 use uuid::Uuid;
@@ -30,6 +30,14 @@
30 30 /// Maximum changes to push in a single batch.
31 31 const PUSH_BATCH_LIMIT: i64 = 500;
32 32
33 + /// Email account columns that sync (config only — credentials stay per-device).
34 + const EMAIL_ACCOUNT_SYNC_COLS: &[&str] = &[
35 + "id", "user_id", "account_name", "email_address",
36 + "imap_server", "imap_port", "smtp_server", "smtp_port",
37 + "username", "use_tls", "created_at", "archive_folder_name",
38 + "auth_type", "jmap_session_url", "jmap_account_id", "sync_interval_minutes",
39 + ];
40 +
33 41 /// Tables in FK-safe order for upserts (parents first).
34 42 const UPSERT_ORDER: &[&str] = &[
35 43 "projects",
@@ -229,12 +237,29 @@
229 237 Ok(total_applied)
230 238 }
231 239
232 - /// Apply remote changes to local DB with triggers suppressed.
240 + /// Apply remote changes to local DB with triggers suppressed and FK enforcement off.
241 + ///
242 + /// FK enforcement is disabled so that tasks with `source_email_id` pointing to
243 + /// emails not yet fetched locally can be inserted without error. Uses a dedicated
244 + /// connection (same pattern as `crates/db-sqlite/src/migrations.rs`).
233 245 async fn apply_remote_changes(pool: &SqlitePool, changes: Vec<ChangeEntry>) -> Result<(), CoreError> {
234 246 // Set applying_remote flag to suppress triggers
235 247 set_sync_state(pool, "applying_remote", "1").await?;
236 248
237 - let result = apply_changes_inner(pool, changes).await;
249 + // Acquire a dedicated connection for FK pragma (pool-level pragma is per-connection)
250 + let mut conn = pool.acquire().await.map_err(CoreError::database)?;
251 +
252 + sqlx::query("PRAGMA foreign_keys = OFF")
253 + .execute(&mut *conn)
254 + .await
255 + .map_err(CoreError::database)?;
256 +
257 + let result = apply_changes_inner(&mut conn, changes).await;
258 +
259 + sqlx::query("PRAGMA foreign_keys = ON")
260 + .execute(&mut *conn)
261 + .await
262 + .map_err(CoreError::database)?;
238 263
239 264 // Always clear the flag, even on error
240 265 set_sync_state(pool, "applying_remote", "0").await?;
@@ -242,7 +267,10 @@
242 267 result
243 268 }
244 269
245 - async fn apply_changes_inner(pool: &SqlitePool, changes: Vec<ChangeEntry>) -> Result<(), CoreError> {
270 + async fn apply_changes_inner(
271 + conn: &mut SqliteConnection,
272 + changes: Vec<ChangeEntry>,
273 + ) -> Result<(), CoreError> {
246 274 // Separate upserts from deletes
247 275 let mut upserts: Vec<&ChangeEntry> = Vec::new();
248 276 let mut deletes: Vec<&ChangeEntry> = Vec::new();
@@ -259,7 +287,7 @@
259 287 for change in &upserts {
260 288 if change.table == *table {
261 289 if let Some(ref data) = change.data {
262 - apply_upsert(pool, table, &change.row_id, data).await?;
290 + apply_upsert(&mut *conn, table, &change.row_id, data).await?;
263 291 }
264 292 }
265 293 }
@@ -269,7 +297,7 @@
269 297 for table in DELETE_ORDER {
270 298 for change in &deletes {
271 299 if change.table == *table {
272 - apply_delete(pool, table, &change.row_id).await?;
300 + apply_delete(&mut *conn, table, &change.row_id).await?;
273 301 }
274 302 }
275 303 }
@@ -312,24 +340,23 @@
312 340 "id", "user_id", "project_id", "name", "description", "position", "target_date",
313 341 "status", "created_at",
314 342 ]),
315 - "email_accounts" => Some(&[
316 - "id", "user_id", "account_name", "email_address", "imap_server", "imap_port",
317 - "smtp_server", "smtp_port", "username", "password", "use_tls", "created_at",
318 - "archive_folder_name", "auth_type", "oauth2_access_token", "oauth2_refresh_token",
319 - "oauth2_token_expires_at", "jmap_session_url", "jmap_account_id",
320 - "sync_interval_minutes",
321 - ]),
343 + "email_accounts" => Some(EMAIL_ACCOUNT_SYNC_COLS),
322 344 _ => None,
323 345 }
324 346 }
325 347
326 348 /// Apply an INSERT OR REPLACE for a remote change.
327 349 async fn apply_upsert(
328 - pool: &SqlitePool,
350 + conn: &mut SqliteConnection,
329 351 table: &str,
330 352 _row_id: &str,
331 353 data: &serde_json::Value,
332 354 ) -> Result<(), CoreError> {
355 + // Email accounts use ON CONFLICT to preserve local credentials
356 + if table == "email_accounts" {
357 + return apply_email_account_upsert(conn, data).await;
358 + }
359 +
333 360 let columns = table_columns(table)
334 361 .ok_or_else(|| CoreError::bad_request(format!("unknown syncable table: {}", table)))?;
335 362
@@ -343,43 +370,94 @@
343 370 let mut query = sqlx::query(&sql);
344 371
345 372 for col in columns {
346 - let val = &data[*col];
347 - match val {
348 - serde_json::Value::String(s) => {
349 - query = query.bind(s.as_str());
350 - }
351 - serde_json::Value::Number(n) => {
352 - if let Some(i) = n.as_i64() {
353 - query = query.bind(i);
354 - } else if let Some(f) = n.as_f64() {
355 - query = query.bind(f);
356 - } else {
357 - query = query.bind(None::<String>);
358 - }
359 - }
360 - serde_json::Value::Bool(b) => {
361 - query = query.bind(if *b { 1i32 } else { 0i32 });
362 - }
363 - serde_json::Value::Null => {
364 - query = query.bind(None::<String>);
365 - }
366 - _ => {
367 - // Arrays/objects: serialize as JSON string
368 - query = query.bind(val.to_string());
369 - }
370 - }
373 + query = bind_json_value(query, &data[*col]);
371 374 }
372 375
373 376 query
374 - .execute(pool)
377 + .execute(&mut *conn)
375 378 .await
376 379 .map_err(CoreError::database)?;
377 380
378 381 Ok(())
379 382 }
380 383
384 + /// Apply an upsert for email_accounts that preserves local credentials.
385 + ///
386 + /// Uses INSERT ... ON CONFLICT(id) DO UPDATE to only touch the 16 config columns,
387 + /// leaving `password`, `oauth2_access_token`, `oauth2_refresh_token`, and
388 + /// `oauth2_token_expires_at` untouched on existing rows. New rows get `password = ''`
389 + /// to satisfy the NOT NULL constraint.
390 + async fn apply_email_account_upsert(
391 + conn: &mut SqliteConnection,
392 + data: &serde_json::Value,
393 + ) -> Result<(), CoreError> {
394 + let cols = EMAIL_ACCOUNT_SYNC_COLS;
395 +
396 + // INSERT columns: 16 sync cols + password (hardcoded to '')
397 + let mut insert_cols: Vec<&str> = cols.to_vec();
398 + insert_cols.push("password");
399 +
400 + let col_list = insert_cols.join(", ");
401 + let placeholders = insert_cols.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
402 +
403 + // ON CONFLICT: only update the 16 sync columns
404 + let update_set = cols
405 + .iter()
406 + .filter(|c| **c != "id")
407 + .map(|c| format!("{} = excluded.{}", c, c))
408 + .collect::<Vec<_>>()
409 + .join(", ");
410 +
411 + let sql = format!(
412 + "INSERT INTO email_accounts ({}) VALUES ({}) ON CONFLICT(id) DO UPDATE SET {}",
413 + col_list, placeholders, update_set
414 + );
415 +
416 + let mut query = sqlx::query(&sql);
417 +
418 + // Bind the 16 sync columns from data
419 + for col in cols {
420 + query = bind_json_value(query, &data[*col]);
421 + }
422 +
423 + // Bind password = '' for the INSERT
424 + query = query.bind("");
425 +
426 + query
427 + .execute(&mut *conn)
428 + .await
429 + .map_err(CoreError::database)?;
430 +
431 + Ok(())
432 + }
433 +
434 + /// Bind a JSON value to a sqlx query.
435 + fn bind_json_value<'q>(
436 + query: sqlx::query::Query<'q, sqlx::Sqlite, sqlx::sqlite::SqliteArguments<'q>>,
437 + val: &'q serde_json::Value,
438 + ) -> sqlx::query::Query<'q, sqlx::Sqlite, sqlx::sqlite::SqliteArguments<'q>> {
439 + match val {
440 + serde_json::Value::String(s) => query.bind(s.as_str()),
441 + serde_json::Value::Number(n) => {
442 + if let Some(i) = n.as_i64() {
443 + query.bind(i)
444 + } else if let Some(f) = n.as_f64() {
445 + query.bind(f)
446 + } else {
447 + query.bind(None::<String>)
448 + }
449 + }
450 + serde_json::Value::Bool(b) => query.bind(if *b { 1i32 } else { 0i32 }),
451 + serde_json::Value::Null => query.bind(None::<String>),
452 + _ => {
453 + // Arrays/objects: serialize as JSON string — need owned String
454 + query.bind(val.to_string())
455 + }
456 + }
457 + }
458 +
381 459 /// Apply a DELETE for a remote change.
382 - async fn apply_delete(pool: &SqlitePool, table: &str, row_id: &str) -> Result<(), CoreError> {
460 + async fn apply_delete(conn: &mut SqliteConnection, table: &str, row_id: &str) -> Result<(), CoreError> {
383 461 // Validate table name is in our whitelist
384 462 if table_columns(table).is_none() {
385 463 return Err(CoreError::bad_request(format!("unknown syncable table: {}", table)));
@@ -388,7 +466,7 @@
388 466 let sql = format!("DELETE FROM {} WHERE id = ?", table);
389 467 sqlx::query(&sql)
390 468 .bind(row_id)
391 - .execute(pool)
469 + .execute(&mut *conn)
392 470 .await
393 471 .map_err(CoreError::database)?;
394 472
@@ -667,7 +745,8 @@
667 745 "user_id": user_id,
668 746 });
669 747
670 - apply_upsert(&pool, "projects", &project_id, &data).await.unwrap();
748 + let mut conn = pool.acquire().await.unwrap();
749 + apply_upsert(&mut conn, "projects", &project_id, &data).await.unwrap();
671 750
672 751 let row: (String,) = sqlx::query_as("SELECT name FROM projects WHERE id = ?")
673 752 .bind(&project_id)
@@ -693,7 +772,8 @@
693 772 "created_at": now,
694 773 "user_id": user_id,
695 774 });
696 - apply_upsert(&pool, "projects", &project_id, &data1).await.unwrap();
775 + let mut conn = pool.acquire().await.unwrap();
776 + apply_upsert(&mut conn, "projects", &project_id, &data1).await.unwrap();
697 777
698 778 let data2 = json!({
699 779 "id": project_id,
@@ -704,7 +784,7 @@
704 784 "created_at": now,
705 785 "user_id": user_id,
706 786 });
707 - apply_upsert(&pool, "projects", &project_id, &data2).await.unwrap();
787 + apply_upsert(&mut conn, "projects", &project_id, &data2).await.unwrap();
708 788
709 789 let row: (String, String) =
710 790 sqlx::query_as("SELECT name, status FROM projects WHERE id = ?")
@@ -720,7 +800,8 @@
720 800 async fn apply_upsert_rejects_unknown_table() {
721 801 let pool = setup_test_db().await;
722 802 let data = json!({"id": "abc"});
723 - let result = apply_upsert(&pool, "nonexistent", "abc", &data).await;
803 + let mut conn = pool.acquire().await.unwrap();
804 + let result = apply_upsert(&mut conn, "nonexistent", "abc", &data).await;
724 805 assert!(result.is_err());
725 806 let err_msg = result.unwrap_err().to_string();
726 807 assert!(err_msg.contains("unknown syncable table"));
@@ -733,6 +814,8 @@
733 814 let project_id = Uuid::new_v4().to_string();
734 815 let now = now_sql();
735 816
817 + let mut conn = pool.acquire().await.unwrap();
818 +
736 819 // Insert a project to be a FK parent for the task
737 820 let data_project = json!({
738 821 "id": project_id,
@@ -743,7 +826,7 @@
743 826 "created_at": now,
744 827 "user_id": user_id,
745 828 });
746 - apply_upsert(&pool, "projects", &project_id, &data_project).await.unwrap();
829 + apply_upsert(&mut conn, "projects", &project_id, &data_project).await.unwrap();
747 830
748 831 let task_id = Uuid::new_v4().to_string();
749 832 let data = json!({
@@ -772,7 +855,7 @@
772 855 "milestone_id": null,
773 856 });
774 857
775 - apply_upsert(&pool, "tasks", &task_id, &data).await.unwrap();
858 + apply_upsert(&mut conn, "tasks", &task_id, &data).await.unwrap();
776 859
777 860 let row: (String,) = sqlx::query_as("SELECT description FROM tasks WHERE id = ?")
778 861 .bind(&task_id)
@@ -791,6 +874,8 @@
791 874 let project_id = Uuid::new_v4().to_string();
792 875 let now = now_sql();
793 876
877 + let mut conn = pool.acquire().await.unwrap();
878 +
794 879 let data = json!({
795 880 "id": project_id,
796 881 "name": "To Delete",
@@ -800,9 +885,9 @@
800 885 "created_at": now,
801 886 "user_id": user_id,
802 887 });
803 - apply_upsert(&pool, "projects", &project_id, &data).await.unwrap();
888 + apply_upsert(&mut conn, "projects", &project_id, &data).await.unwrap();
804 889
805 - apply_delete(&pool, "projects", &project_id).await.unwrap();
890 + apply_delete(&mut conn, "projects", &project_id).await.unwrap();
806 891
807 892 let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM projects WHERE id = ?")
808 893 .bind(&project_id)
@@ -815,7 +900,8 @@
815 900 #[tokio::test]
816 901 async fn apply_delete_rejects_unknown_table() {
817 902 let pool = setup_test_db().await;
818 - let result = apply_delete(&pool, "not_a_table", "abc").await;
903 + let mut conn = pool.acquire().await.unwrap();
904 + let result = apply_delete(&mut conn, "not_a_table", "abc").await;
819 905 assert!(result.is_err());
820 906 let err_msg = result.unwrap_err().to_string();
821 907 assert!(err_msg.contains("unknown syncable table"));
@@ -824,8 +910,9 @@
824 910 #[tokio::test]
825 911 async fn apply_delete_is_idempotent() {
826 912 let pool = setup_test_db().await;
913 + let mut conn = pool.acquire().await.unwrap();
827 914 // Deleting a non-existent row should succeed silently.
828 - let result = apply_delete(&pool, "projects", "nonexistent-id").await;
915 + let result = apply_delete(&mut conn, "projects", "nonexistent-id").await;
829 916 assert!(result.is_ok());
830 917 }
831 918
@@ -879,7 +966,8 @@
879 966
880 967 // Should succeed despite task coming before project in the input —
881 968 // apply_changes_inner iterates UPSERT_ORDER so projects is applied first.
882 - apply_changes_inner(&pool, changes).await.unwrap();
969 + let mut conn = pool.acquire().await.unwrap();
970 + apply_changes_inner(&mut conn, changes).await.unwrap();
883 971
884 972 let project: (String,) = sqlx::query_as("SELECT name FROM projects WHERE id = ?")
885 973 .bind(&project_id)
@@ -905,14 +993,16 @@
905 993 let subtask_id = Uuid::new_v4().to_string();
906 994 let now = now_sql();
907 995
996 + let mut conn = pool.acquire().await.unwrap();
997 +
908 998 // Insert parent -> child -> grandchild
909 - apply_upsert(&pool, "projects", &project_id, &json!({
999 + apply_upsert(&mut conn, "projects", &project_id, &json!({
910 1000 "id": project_id, "name": "P", "description": "",
911 1001 "project_type": "Job", "status": "Active",
912 1002 "created_at": now, "user_id": user_id,
913 1003 })).await.unwrap();
914 1004
915 - apply_upsert(&pool, "tasks", &task_id, &json!({
1005 + apply_upsert(&mut conn, "tasks", &task_id, &json!({
916 1006 "id": task_id, "project_id": project_id, "description": "T",
917 1007 "status": "Pending", "priority": "Low", "due": null,
918 1008 "tags": null, "urgency": 10, "recurrence": "None",
@@ -925,7 +1015,7 @@
925 1015 "contact_id": null, "milestone_id": null,
926 1016 })).await.unwrap();
927 1017
928 - apply_upsert(&pool, "subtasks", &subtask_id, &json!({
1018 + apply_upsert(&mut conn, "subtasks", &subtask_id, &json!({
929 1019 "id": subtask_id, "task_id": task_id, "text": "Sub",
930 1020 "is_completed": false, "position": 0,
931 1021 "created_at": now, "linked_task_id": null,
@@ -939,7 +1029,7 @@
939 1029 change("subtasks", ChangeOp::Delete, &subtask_id, None),
940 1030 ];
941 1031
942 - apply_changes_inner(&pool, changes).await.unwrap();
1032 + apply_changes_inner(&mut conn, changes).await.unwrap();
943 1033
944 1034 let count: (i64,) =
945 1035 sqlx::query_as("SELECT COUNT(*) FROM projects WHERE id = ?")
@@ -964,9 +1054,11 @@
964 1054 let user_id = create_test_user(&pool).await;
965 1055 let now = now_sql();
966 1056
1057 + let mut conn = pool.acquire().await.unwrap();
1058 +
967 1059 // Create a project to delete later
968 1060 let delete_id = Uuid::new_v4().to_string();
969 - apply_upsert(&pool, "projects", &delete_id, &json!({
1061 + apply_upsert(&mut conn, "projects", &delete_id, &json!({
970 1062 "id": delete_id, "name": "Will Delete", "description": "",
971 1063 "project_type": "Job", "status": "Active",
972 1064 "created_at": now, "user_id": user_id,
@@ -983,7 +1075,7 @@
983 1075 }))),
984 1076 ];
985 1077
986 - apply_changes_inner(&pool, changes).await.unwrap();
1078 + apply_changes_inner(&mut conn, changes).await.unwrap();
987 1079
988 1080 // The old project was deleted
989 1081 let count: (i64,) =
@@ -1009,7 +1101,8 @@
1009 1101
1010 1102 // INSERT with no data should be skipped (data is None)
1011 1103 let changes = vec![change("projects", ChangeOp::Insert, "abc", None)];
1012 - let result = apply_changes_inner(&pool, changes).await;
1104 + let mut conn = pool.acquire().await.unwrap();
1105 + let result = apply_changes_inner(&mut conn, changes).await;
1013 1106 assert!(result.is_ok());
1014 1107
1015 1108 let count: (i64,) =
@@ -1084,29 +1177,9 @@
1084 1177 async fn apply_remote_changes_clears_flag_on_error() {
1085 1178 let pool = setup_test_db().await;
1086 1179
1087 - // Force an error by directly calling apply_upsert on an unknown table,
1088 - // which demonstrates the error path. For apply_remote_changes itself,
1089 - // tables not in UPSERT_ORDER are silently skipped (not errors).
1090 - // Instead, test with a task whose data is malformed enough to cause
1091 - // a DB error — insert a task referencing a non-existent table column.
1092 - //
1093 - // The cleanest approach: manually set the flag, call apply_changes_inner
1094 - // with something that errors, then verify the flag was still cleared
1095 - // by apply_remote_changes.
1096 -
1097 - // We can trigger a real error by having apply_delete called on a table
1098 - // that IS in DELETE_ORDER but whose SQL fails. Actually, apply_delete
1099 - // validates via table_columns, so any table in DELETE_ORDER will pass
1100 - // validation and just do "DELETE FROM X WHERE id = ?" which always
1101 - // succeeds (even if no rows match).
1102 - //
1103 - // The most reliable approach: verify the cleanup pattern by checking
1104 - // the flag is always "0" after apply_remote_changes returns, regardless
1105 - // of content. Test that apply_upsert errors are propagated correctly
1106 - // as a separate unit test.
1107 -
1108 1180 // Test: apply_upsert on unknown table returns BadRequest
1109 - let result = apply_upsert(&pool, "fake_table", "id", &json!({"id": "x"})).await;
1181 + let mut conn = pool.acquire().await.unwrap();
1182 + let result = apply_upsert(&mut conn, "fake_table", "id", &json!({"id": "x"})).await;
1110 1183 assert!(result.is_err());
1111 1184 match result.unwrap_err() {
1112 1185 CoreError::BadRequest(msg) => assert!(msg.contains("unknown syncable table")),
@@ -1323,8 +1396,10 @@
1323 1396 let email_id = Uuid::new_v4().to_string();
1324 1397 let now = now_sql();
1325 1398
1399 + let mut conn = pool.acquire().await.unwrap();
1400 +
1326 1401 // Insert contact (parent)
1327 - apply_upsert(&pool, "contacts", &contact_id, &json!({
1402 + apply_upsert(&mut conn, "contacts", &contact_id, &json!({
1328 1403 "id": contact_id, "user_id": user_id,
1329 1404 "display_name": "Test Contact", "nickname": null,
1330 1405 "company": null, "title": null, "notes": null,
@@ -1333,7 +1408,7 @@
1333 1408 })).await.unwrap();
1334 1409
1335 1410 // Insert contact_email (child)
1336 - apply_upsert(&pool, "contact_emails", &email_id, &json!({
1411 + apply_upsert(&mut conn, "contact_emails", &email_id, &json!({
1337 1412 "id": email_id, "contact_id": contact_id,
1338 1413 "address": "test@example.com", "label": "work",
1339 1414 "is_primary": true,
@@ -1357,7 +1432,9 @@
1357 1432 let email_id = Uuid::new_v4().to_string();
1358 1433 let now = now_sql();
1359 1434
1360 - apply_upsert(&pool, "contacts", &contact_id, &json!({
1435 + let mut conn = pool.acquire().await.unwrap();
1436 +
1437 + apply_upsert(&mut conn, "contacts", &contact_id, &json!({
1361 1438 "id": contact_id, "user_id": user_id,
1362 1439 "display_name": "Bool Test", "nickname": null,
1363 1440 "company": null, "title": null, "notes": null,
@@ -1366,7 +1443,7 @@
1366 1443 })).await.unwrap();
1367 1444
1368 1445 // is_primary as a JSON boolean — should be stored as integer
1369 - apply_upsert(&pool, "contact_emails", &email_id, &json!({
1446 + apply_upsert(&mut conn, "contact_emails", &email_id, &json!({
1370 1447 "id": email_id, "contact_id": contact_id,
1371 1448 "address": "bool@test.com", "label": "home",
1372 1449 "is_primary": true,
@@ -1419,8 +1496,12 @@
1419 1496 .unwrap();
1420 1497 let parsed: serde_json::Value = serde_json::from_str(&data.0).unwrap();
1421 1498 let obj = parsed.as_object().unwrap();
1422 - assert_eq!(obj.len(), 20, "trigger data should have 20 keys");
1423 - assert!(!obj.contains_key("last_sync_at"), "last_sync_at should not be synced");
1499 + assert_eq!(obj.len(), 16, "trigger data should have 16 keys (config only)");
1500 + assert!(!obj.contains_key("password"), "password should not be in trigger data");
1501 + assert!(!obj.contains_key("oauth2_access_token"), "oauth2_access_token should not be in trigger data");
1502 + assert!(!obj.contains_key("oauth2_refresh_token"), "oauth2_refresh_token should not be in trigger data");
1503 + assert!(!obj.contains_key("oauth2_token_expires_at"), "oauth2_token_expires_at should not be in trigger data");
1504 + assert!(!obj.contains_key("last_sync_at"), "last_sync_at should not be in trigger data");
1424 1505 assert_eq!(parsed["account_name"], "Work");
1425 1506 }
1426 1507
@@ -1465,6 +1546,7 @@
1465 1546
1466 1547 let parsed: serde_json::Value = serde_json::from_str(&row.1).unwrap();
1467 1548 assert_eq!(parsed["account_name"], "Personal");
1549 + assert!(!parsed.as_object().unwrap().contains_key("password"), "update trigger should not include password");
1468 1550 }
1469 1551
1470 1552 #[tokio::test]
@@ -1508,9 +1590,224 @@
1508 1590 }
1509 1591
Lines truncated
@@ -886,9 +886,9 @@
886 886 #[test]
887 887 fn json_to_dynamic_primitives() {
888 888 assert_eq!(json_to_dynamic(serde_json::Value::Null).type_name(), "()");
889 - assert_eq!(json_to_dynamic(serde_json::Value::Bool(true)).as_bool().unwrap(), true);
889 + assert!(json_to_dynamic(serde_json::Value::Bool(true)).as_bool().unwrap());
890 890 assert_eq!(json_to_dynamic(serde_json::json!(42)).as_int().unwrap(), 42);
891 - assert_eq!(json_to_dynamic(serde_json::json!(3.14)).as_float().unwrap(), 3.14);
891 + assert_eq!(json_to_dynamic(serde_json::json!(2.72)).as_float().unwrap(), 2.72);
892 892 assert_eq!(
893 893 json_to_dynamic(serde_json::json!("hello")).into_string().unwrap(),
894 894 "hello"
@@ -903,7 +903,7 @@
903 903 assert_eq!(arr.len(), 3);
904 904 assert_eq!(arr[0].as_int().unwrap(), 1);
905 905 assert_eq!(arr[1].clone().into_string().unwrap(), "two");
906 - assert_eq!(arr[2].as_bool().unwrap(), true);
906 + assert!(arr[2].as_bool().unwrap());
907 907 }
908 908
909 909 #[test]
@@ -820,9 +820,9 @@
820 820
821 821 // ImportOptions derives Default, so has_header is false (the
822 822 // serde default_true function only applies during deserialization).
823 - assert_eq!(map.get("has_header").unwrap().as_bool().unwrap(), false);
824 - assert!(map.get("delimiter").is_none());
825 - assert!(map.get("date_format").is_none());
823 + assert!(!map.get("has_header").unwrap().as_bool().unwrap());
824 + assert!(!map.contains_key("delimiter"));
825 + assert!(!map.contains_key("date_format"));
826 826 }
827 827
828 828 #[test]
@@ -840,7 +840,7 @@
840 840
841 841 let map = options_to_rhai_map(&options);
842 842
843 - assert_eq!(map.get("has_header").unwrap().as_bool().unwrap(), false);
843 + assert!(!map.get("has_header").unwrap().as_bool().unwrap());
844 844 assert_eq!(
845 845 map.get("delimiter").unwrap().clone().into_string().unwrap(),
846 846 "\t"