Compare commits

..

533 Commits
main ... propre

Author SHA1 Message Date
43ebc8f7bf cleaning hard 2 2025-05-03 13:50:19 +02:00
35d4e37d79 cleaning hard 2025-05-02 18:19:46 +02:00
a4412c081a cleaning 2025-05-02 18:05:28 +02:00
3b81ad058a auth flow 2025-05-02 16:50:07 +02:00
9099af78d7 auth flow 2025-05-02 16:47:55 +02:00
a5f672726a auth flow 2025-05-02 16:44:36 +02:00
e76e92b695 auth flow 2025-05-02 16:41:42 +02:00
d488979109 auth flow 2025-05-02 16:38:58 +02:00
3be309c0fe auth flow 2025-05-02 13:08:49 +02:00
1443d8d9b2 auth flow 2025-05-02 13:05:56 +02:00
ce09b18a6f auth flow 2025-05-02 13:01:33 +02:00
a9294b0231 auth flow 2025-05-02 12:56:20 +02:00
d7922351c0 auth flow 2025-05-02 12:52:40 +02:00
fa05961404 auth flow 2025-05-02 12:48:01 +02:00
ed199d7a00 auth flow 2025-05-02 12:38:04 +02:00
32f77425de auth flow 2025-05-02 12:33:43 +02:00
90867df452 auth flow 2025-05-02 12:10:18 +02:00
645907b38b auth flow 2025-05-02 12:08:25 +02:00
500d7de548 auth flow 2025-05-02 11:48:24 +02:00
6c28c51373 auth flow 2025-05-02 11:41:43 +02:00
086ab9416a auth flow 2025-05-02 11:24:22 +02:00
31537efe18 auth flow 2025-05-02 11:13:19 +02:00
b7416e33ea auth flow 2025-05-02 11:10:53 +02:00
d882325da2 auth flow 2025-05-02 11:08:02 +02:00
ecd587fd8c auth flow 2025-05-02 11:01:23 +02:00
a171e4a713 auth flow 2025-05-02 10:58:27 +02:00
97479aaa35 auth flow 2025-05-02 10:50:40 +02:00
44f278b7ab auth flow 2025-05-02 10:49:47 +02:00
279a5a34ab auth flow 2025-05-02 10:47:22 +02:00
ef4f73ad9e auth flow 2025-05-02 10:45:52 +02:00
dde98bb598 courrier msft oauth 2025-05-02 10:41:16 +02:00
f60e8520e7 courrier msft oauth 2025-05-02 10:38:39 +02:00
0ab1441fe4 courrier msft oauth 2025-05-02 10:32:19 +02:00
92b439dabf courrier msft oauth 2025-05-02 10:07:52 +02:00
92e0c0dd3b courrier msft oauth 2025-05-02 10:02:58 +02:00
dcad2f160d courrier msft oauth 2025-05-02 09:58:37 +02:00
ab1f541b53 courrier msft oauth 2025-05-02 09:53:17 +02:00
06de428e61 courrier msft oauth 2025-05-02 09:49:32 +02:00
758607d05f courrier msft oauth 2025-05-02 09:46:45 +02:00
49dd79e3ba courrier msft oauth 2025-05-02 09:41:19 +02:00
b97c2310b8 courrier msft oauth 2025-05-02 09:36:36 +02:00
ad7cf7514e courrier msft oauth 2025-05-02 09:28:49 +02:00
cb095ab7ee courrier msft oauth 2025-05-02 09:15:59 +02:00
a7dbd7cd85 courrier preview 2025-05-02 09:10:52 +02:00
3fb8266a9e courrier preview 2025-05-01 22:26:02 +02:00
e88ef38222 courrier preview 2025-05-01 22:18:45 +02:00
8d6e0c85b2 courrier preview 2025-05-01 22:12:24 +02:00
fc8276494d courrier preview 2025-05-01 22:06:21 +02:00
b859b733f6 courrier preview 2025-05-01 21:53:46 +02:00
3734a976d2 courrier preview 2025-05-01 21:42:50 +02:00
2eaca502b2 courrier preview 2025-05-01 21:32:02 +02:00
96e16ea80d courrier preview 2025-05-01 21:24:06 +02:00
15043f0b0c courrier preview 2025-05-01 21:20:17 +02:00
76ab4c0e7d courrier preview 2025-05-01 21:13:48 +02:00
dc8febe5f8 courrier preview 2025-05-01 21:08:50 +02:00
e10dd6e94a courrier preview 2025-05-01 21:04:38 +02:00
4a2297f4b0 courrier preview 2025-05-01 20:57:35 +02:00
81ed21b6b4 courrier preview 2025-05-01 20:47:46 +02:00
bcd4c8cd54 courrier preview 2025-05-01 20:46:27 +02:00
487fd96490 courrier preview 2025-05-01 20:42:03 +02:00
6498f7ca65 courrier preview 2025-05-01 20:40:00 +02:00
0d88409508 courrier preview 2025-05-01 20:35:02 +02:00
bbe4bc06c2 courrier preview 2025-05-01 20:29:45 +02:00
c1aa1e8f10 courrier preview 2025-05-01 20:23:52 +02:00
2900e0d188 courrier preview 2025-05-01 20:15:34 +02:00
7a4a9f2c14 courrier preview 2025-05-01 20:07:24 +02:00
08ccb6d174 courrier preview 2025-05-01 19:59:14 +02:00
5a6036b0d5 courrier preview 2025-05-01 19:55:05 +02:00
3420c5be5c courrier preview 2025-05-01 17:57:35 +02:00
fbfababb22 courrier preview 2025-05-01 17:52:18 +02:00
caa6a62e67 courrier preview 2025-05-01 17:43:43 +02:00
e8989f2016 courrier preview 2025-05-01 17:32:55 +02:00
0d4fc59904 courrier preview 2025-05-01 17:28:30 +02:00
e9f104f40b courrier preview 2025-05-01 17:22:15 +02:00
149a49d6f3 courrier preview 2025-05-01 17:10:09 +02:00
ddcb428233 courrier preview 2025-05-01 17:01:26 +02:00
5160967c75 courrier preview 2025-05-01 16:45:23 +02:00
0db546ad79 courrier preview 2025-05-01 16:36:44 +02:00
d582a2ba30 courrier preview 2025-05-01 16:30:42 +02:00
f9244f5a20 courrier preview 2025-05-01 16:25:56 +02:00
1dbf66cdec courrier preview 2025-05-01 16:08:22 +02:00
cdd5bd98bc courrier preview 2025-05-01 15:49:25 +02:00
1f6032941a courrier preview 2025-05-01 15:36:05 +02:00
2b02742bc4 courrier preview 2025-05-01 15:29:38 +02:00
61c29ced21 courrier preview 2025-05-01 15:18:00 +02:00
738100a38a courrier preview 2025-05-01 13:13:51 +02:00
0c67e6a4f4 courrier preview 2025-05-01 13:09:21 +02:00
c15e79c5a1 courrier preview 2025-05-01 12:50:00 +02:00
080b9ab795 courrier preview 2025-05-01 12:47:36 +02:00
1211b43f46 courrier preview 2025-05-01 12:38:32 +02:00
9c2c909f71 courrier preview 2025-05-01 12:37:35 +02:00
af8ba0b7b5 courrier preview 2025-05-01 12:36:23 +02:00
0813a70184 courrier preview 2025-05-01 12:33:30 +02:00
36920bc9d5 courrier preview 2025-05-01 12:30:13 +02:00
a62ccf9c0c courrier preview 2025-05-01 12:23:53 +02:00
3c57a3ed65 courrier preview 2025-05-01 12:19:59 +02:00
b149c52931 courrier preview 2025-05-01 12:13:20 +02:00
fba2b1213f courrier preview 2025-05-01 11:48:29 +02:00
0251b08113 courrier preview 2025-05-01 11:44:58 +02:00
1ced248940 courrier preview 2025-05-01 11:37:05 +02:00
7f87e4f00a courrier preview 2025-05-01 11:20:15 +02:00
c4958d7e4c courrier preview 2025-05-01 11:03:43 +02:00
6c9f2d86a6 courrier preview 2025-05-01 10:47:24 +02:00
86581cea72 courrier preview 2025-05-01 10:31:52 +02:00
83af1599f9 courrier preview 2025-05-01 10:17:28 +02:00
c4994ad175 courrier preview 2025-05-01 10:07:47 +02:00
0a13a74b8b courrier preview 2025-05-01 10:06:48 +02:00
636343019c courrier preview 2025-05-01 09:59:00 +02:00
975dc1e1f8 courrier preview 2025-05-01 09:54:17 +02:00
d88fc133d2 courrier preview 2025-05-01 09:43:25 +02:00
193a265109 courrier preview 2025-05-01 09:32:23 +02:00
9e5c2cb92e courrier preview 2025-04-30 23:54:43 +02:00
bf19cd5015 courrier preview 2025-04-30 23:43:40 +02:00
7fcf4d5ea8 courrier preview 2025-04-30 23:41:10 +02:00
d6e3acd17a courrier preview 2025-04-30 23:32:16 +02:00
022d37f1be courrier preview 2025-04-30 23:27:36 +02:00
ef1923baa6 courrier preview 2025-04-30 23:25:41 +02:00
3584f72bf5 courrier preview 2025-04-30 23:18:20 +02:00
f9e0b323ce courrier preview 2025-04-30 23:13:14 +02:00
ad03dba3bf courrier preview 2025-04-30 23:07:38 +02:00
a30fd6d6c0 courrier preview 2025-04-30 23:05:02 +02:00
81a107147c courrier preview 2025-04-30 23:01:36 +02:00
5adf1a9a62 courrier preview 2025-04-30 22:59:04 +02:00
5ea88d2d6c courrier preview 2025-04-30 22:54:53 +02:00
bd1b4c9a22 courrier preview 2025-04-30 22:50:11 +02:00
567da9d741 courrier preview 2025-04-30 22:45:26 +02:00
748d0bbff4 courrier preview 2025-04-30 22:38:36 +02:00
a82fb22b93 courrier preview 2025-04-30 22:25:48 +02:00
9cde8ac9d0 courrier preview 2025-04-30 22:14:45 +02:00
2113c27a68 courrier preview 2025-04-30 22:08:40 +02:00
c501784bbe courrier preview 2025-04-30 22:05:17 +02:00
41480e6666 courrier preview 2025-04-30 22:00:20 +02:00
c355585355 courrier preview 2025-04-30 21:52:21 +02:00
b5411945b3 courrier preview 2025-04-30 21:42:42 +02:00
b71336e749 courrier preview 2025-04-30 21:15:50 +02:00
d12a6c4670 courrier preview 2025-04-30 20:55:17 +02:00
3f017f82f8 courrier formatting 2025-04-30 20:39:11 +02:00
4c39241d3c courrier formatting 2025-04-30 20:32:24 +02:00
3fa6c68464 courrier formatting 2025-04-30 18:18:59 +02:00
b2170dd9d5 courrier formatting 2025-04-30 18:02:21 +02:00
e61b0855bd courrier formatting 2025-04-30 17:56:39 +02:00
9d23eb3297 courrier formatting 2025-04-30 17:52:34 +02:00
cd677d1736 courrier formatting 2025-04-30 17:48:09 +02:00
dc919c58a9 courrier formatting 2025-04-30 17:34:57 +02:00
029c0e44c9 courrier formatting 2025-04-30 17:31:45 +02:00
67ab46879c courrier formatting 2025-04-30 17:26:29 +02:00
af18853d30 courrier formatting 2025-04-30 17:24:07 +02:00
e00f9e44f5 courrier formatting 2025-04-30 17:17:31 +02:00
b384ad1193 courrier formatting 2025-04-30 17:12:19 +02:00
2b017a9395 courrier formatting 2025-04-30 17:07:09 +02:00
65b1061088 courrier formatting 2025-04-30 17:02:49 +02:00
f9db3efd39 courrier formatting 2025-04-30 16:50:26 +02:00
98413f87ee courrier formatting 2025-04-30 16:45:45 +02:00
2cceb8961f courrier formatting 2025-04-30 16:44:10 +02:00
6c990b414b courrier formatting 2025-04-30 16:29:41 +02:00
ceab45b7aa courrier formatting 2025-04-30 16:25:15 +02:00
1870cc0f6e courrier formatting 2025-04-30 16:20:20 +02:00
9dc4ef2fac courrier formatting 2025-04-30 16:14:33 +02:00
fcf1631e0f courrier formatting 2025-04-30 16:05:54 +02:00
36d4fb8c40 courrier formatting 2025-04-30 16:01:53 +02:00
f38e1bfc47 courrier formatting 2025-04-30 15:51:33 +02:00
d1a8b3f350 courrier formatting 2025-04-30 15:49:14 +02:00
e02ee396dc courrier formatting 2025-04-30 15:44:06 +02:00
f011bfc43e courrier formatting 2025-04-30 15:43:16 +02:00
53e6c3f7c8 courrier formatting 2025-04-30 15:38:01 +02:00
a7b023e359 courrier multi account restore compose 2025-04-30 15:29:08 +02:00
363e999dcd courrier multi account restore compose 2025-04-30 15:15:51 +02:00
60cb617d7f courrier multi account restore compose 2025-04-30 14:59:53 +02:00
aabf043b60 courrier multi account restore compose 2025-04-30 14:52:08 +02:00
43067c6240 courrier multi account restore compose 2025-04-30 14:46:40 +02:00
eb557d16dd courrier multi account restore compose 2025-04-30 14:40:01 +02:00
6939ad5e35 courrier multi account restore compose 2025-04-30 14:37:01 +02:00
9b41fcbc01 courrier multi account restore compose 2025-04-30 14:32:25 +02:00
96fc0fe33e courrier multi account restore compose 2025-04-30 14:27:58 +02:00
bd0a906796 courrier multi account restore compose 2025-04-30 14:26:54 +02:00
e5022de6be courrier multi account restore compose 2025-04-30 14:22:51 +02:00
5138649fcd courrier multi account restore compose 2025-04-30 14:18:02 +02:00
f77f228f07 courrier multi account restore compose 2025-04-30 14:14:51 +02:00
952144c03c courrier multi account restore compose 2025-04-30 14:13:53 +02:00
a9e85ca5f2 courrier multi account restore compose 2025-04-30 14:10:37 +02:00
63eeb10033 courrier multi account restore compose 2025-04-30 13:55:45 +02:00
608005d9ee courrier multi account restore compose 2025-04-30 13:21:53 +02:00
f0710c474b courrier multi account restore compose 2025-04-30 13:13:12 +02:00
f306d94729 courrier multi account restore compose 2025-04-30 13:12:23 +02:00
445c90e852 courrier multi account restore compose 2025-04-30 13:12:04 +02:00
d838cb9db9 courrier multi account restore compose 2025-04-30 13:03:40 +02:00
21accd5224 courrier multi account restore compose 2025-04-30 12:57:32 +02:00
9f0ca0e6f5 courrier multi account restore compose 2025-04-30 12:47:52 +02:00
346b766b7f courrier multi account restore compose 2025-04-30 12:37:10 +02:00
a9012dec69 courrier multi account restore compose 2025-04-29 12:00:36 +02:00
7c9535cb25 courrier multi account restore compose 2025-04-29 11:53:20 +02:00
43c4bbd0b1 courrier multi account restore compose 2025-04-29 11:37:29 +02:00
2fada01eba courrier multi account restore compose 2025-04-29 11:29:48 +02:00
0368bd1069 courrier multi account restore compose 2025-04-29 11:24:39 +02:00
764f194a72 courrier multi account restore compose 2025-04-29 11:18:25 +02:00
6bdc3bba5a courrier multi account restore compose 2025-04-29 11:06:46 +02:00
8b1a160c21 courrier multi account restore compose 2025-04-29 10:58:30 +02:00
686ea6cb53 courrier multi account restore compose 2025-04-29 10:55:00 +02:00
1a380f973c courrier multi account restore compose 2025-04-29 10:48:54 +02:00
4e7bdb1631 courrier multi account restore compose 2025-04-29 10:45:56 +02:00
d34bf5202c courrier multi account restore compose 2025-04-29 10:35:20 +02:00
7a33f610c6 courrier multi account restore compose 2025-04-29 10:19:22 +02:00
42d2acdaf2 courrier multi account restore compose 2025-04-29 10:13:44 +02:00
7b4876b669 courrier multi account restore compose 2025-04-29 09:56:14 +02:00
98fe6f5eae courrier multi account restore compose 2025-04-29 09:50:10 +02:00
a18751fe2a courrier multi account restore compose 2025-04-29 09:47:29 +02:00
9c62add6d2 courrier multi account restore compose 2025-04-29 09:41:43 +02:00
5bade2283c courrier multi account restore compose 2025-04-29 09:39:13 +02:00
c2bb904fde courrier multi account restore compose 2025-04-29 09:31:57 +02:00
2fb4fcd069 courrier multi account restore compose 2025-04-29 09:13:52 +02:00
d6c6832a21 courrier multi account restore compose 2025-04-29 09:04:15 +02:00
53b95dd240 courrier multi account restore compose 2025-04-29 08:29:21 +02:00
6638347f92 courrier multi account restore compose 2025-04-28 21:25:56 +02:00
8967b27f17 courrier multi account restore compose 2025-04-28 21:22:56 +02:00
5fb4e623db courrier multi account restore compose 2025-04-28 21:19:44 +02:00
727f87343d courrier multi account restore compose 2025-04-28 21:17:41 +02:00
6fe809a74b courrier multi account restore compose 2025-04-28 21:16:09 +02:00
7d331cf87c courrier multi account restore compose 2025-04-28 21:12:46 +02:00
7963852af6 courrier multi account restore compose 2025-04-28 21:10:05 +02:00
38d11a5dfd courrier multi account restore compose 2025-04-28 21:07:04 +02:00
86a5887e83 courrier multi account restore compose 2025-04-28 21:04:56 +02:00
6b3d93fce4 courrier multi account restore compose 2025-04-28 21:01:19 +02:00
be119e02c6 courrier multi account restore compose 2025-04-28 20:58:41 +02:00
b62ae2d849 courrier multi account restore compose 2025-04-28 20:56:19 +02:00
e7890356e1 courrier multi account restore compose 2025-04-28 20:52:41 +02:00
3d72af9300 courrier multi account restore compose 2025-04-28 20:49:42 +02:00
6a7d69ecf8 courrier multi account restore compose 2025-04-28 20:47:09 +02:00
9fa4f807eb courrier multi account restore compose 2025-04-28 20:37:05 +02:00
f05a982a3a courrier multi account restore compose 2025-04-28 20:23:57 +02:00
6431f8d908 courrier multi account restore compose 2025-04-28 20:16:07 +02:00
f7d6bfb8d2 courrier multi account restore compose 2025-04-28 20:11:57 +02:00
8cf5a72f41 courrier multi account restore compose 2025-04-28 20:09:52 +02:00
202a0e07d8 courrier multi account restore compose 2025-04-28 20:05:02 +02:00
1dac8ec1aa courrier multi account restore compose 2025-04-28 19:42:42 +02:00
d3776c1026 courrier multi account restore compose 2025-04-28 19:38:59 +02:00
e62d197d2c courrier multi account restore compose 2025-04-28 19:28:19 +02:00
6c39027229 courrier multi account restore compose 2025-04-28 19:25:05 +02:00
337afd405e courrier multi account restore compose 2025-04-28 19:21:45 +02:00
90f2c61c3c courrier multi account restore compose 2025-04-28 19:12:02 +02:00
464a30c31b courrier multi account restore compose 2025-04-28 19:08:09 +02:00
daf1fd5e92 courrier multi account restore compose 2025-04-28 19:06:05 +02:00
188895da1b courrier multi account restore compose 2025-04-28 19:01:45 +02:00
fdd91c4b3d courrier multi account restore compose 2025-04-28 18:52:24 +02:00
df1570395d courrier multi account restore compose 2025-04-28 18:36:59 +02:00
793e6c738b courrier multi account restore compose 2025-04-28 18:35:00 +02:00
f7b9c14db3 courrier multi account restore compose 2025-04-28 18:30:46 +02:00
325961c8ba courrier multi account restore compose 2025-04-28 18:21:59 +02:00
5981267f68 courrier multi account restore compose 2025-04-28 18:21:49 +02:00
d3ff8e6b19 courrier multi account restore compose 2025-04-28 18:18:37 +02:00
c734f52444 courrier multi account restore compose 2025-04-28 18:08:44 +02:00
a26c2bfa03 courrier multi account restore compose 2025-04-28 18:01:13 +02:00
fce401a569 courrier multi account restore compose 2025-04-28 17:53:54 +02:00
b17ac56b50 courrier multi account restore compose 2025-04-28 17:39:08 +02:00
f1f8a778bb courrier multi account restore compose 2025-04-28 17:31:31 +02:00
cc4201df4d courrier multi account restore compose 2025-04-28 17:29:33 +02:00
a35cf88c70 courrier multi account restore compose 2025-04-28 17:27:52 +02:00
2f53564b1a courrier multi account restore compose 2025-04-28 17:23:40 +02:00
ab75d01666 courrier multi account restore compose 2025-04-28 17:22:15 +02:00
cc74bfc728 courrier multi account restore compose 2025-04-28 17:19:14 +02:00
74dcb1b8ba courrier multi account restore compose 2025-04-28 17:15:43 +02:00
37aa5aded9 courrier multi account restore compose 2025-04-28 17:13:54 +02:00
091f915f8f courrier multi account restore compose 2025-04-28 17:12:36 +02:00
49bd135347 courrier multi account restore compose 2025-04-28 17:10:55 +02:00
55cdee80a0 courrier multi account restore compose 2025-04-28 17:08:13 +02:00
d1a873dcea courrier multi account restore compose 2025-04-28 16:52:29 +02:00
f17d52b4ef courrier multi account restore compose 2025-04-28 16:43:45 +02:00
4da979c51d courrier multi account restore compose 2025-04-28 16:42:13 +02:00
7fc8e83330 courrier multi account restore compose 2025-04-28 16:36:09 +02:00
5152f9ded0 courrier multi account restore compose 2025-04-28 15:37:36 +02:00
427392f0d0 courrier multi account restore compose 2025-04-28 15:34:32 +02:00
3b455d33d4 courrier multi account restore compose 2025-04-28 15:31:52 +02:00
efbc11bee1 courrier multi account restore compose 2025-04-28 15:30:36 +02:00
f47596bd9f courrier multi account restore compose 2025-04-28 15:28:18 +02:00
85efb768cf courrier multi account restore compose 2025-04-28 15:26:02 +02:00
62101bd1d2 courrier multi account restore compose 2025-04-28 15:22:31 +02:00
814d63f434 courrier multi account restore compose 2025-04-28 15:19:31 +02:00
3cb423a9e2 courrier multi account restore compose 2025-04-28 15:17:30 +02:00
266805950e courrier multi account restore compose 2025-04-28 15:12:38 +02:00
e2805dca07 courrier multi account restore compose 2025-04-28 15:09:35 +02:00
7e18722d3c courrier multi account restore compose 2025-04-28 15:06:52 +02:00
098075010f courrier multi account restore compose 2025-04-28 15:02:52 +02:00
3bb31e580e courrier multi account restore compose 2025-04-28 14:56:18 +02:00
ed39772a28 courrier multi account restore compose 2025-04-28 14:55:29 +02:00
22f693884e courrier multi account restore compose 2025-04-28 14:52:05 +02:00
5f2da42848 courrier multi account restore compose 2025-04-28 14:49:35 +02:00
ccd8b8d762 courrier multi account restore compose 2025-04-28 14:46:09 +02:00
c102b6e89b courrier multi account restore compose 2025-04-28 14:43:39 +02:00
795a9f4629 courrier multi account restore compose 2025-04-28 14:41:44 +02:00
d6728947c6 courrier multi account restore compose 2025-04-28 14:38:26 +02:00
2f76050352 courrier multi account restore compose 2025-04-28 14:37:11 +02:00
181375fe08 courrier multi account restore compose 2025-04-28 14:35:48 +02:00
1506cc7390 courrier multi account restore compose 2025-04-28 14:34:24 +02:00
d4b28b7974 courrier multi account restore compose 2025-04-28 14:32:13 +02:00
99cb4e96f4 courrier multi account restore compose 2025-04-28 14:27:08 +02:00
093125df23 courrier multi account restore compose 2025-04-28 14:21:41 +02:00
9f5617b424 courrier multi account restore compose 2025-04-28 13:54:08 +02:00
bd8b39ad9b courrier multi account restore compose 2025-04-28 13:52:42 +02:00
0fbc339447 courrier multi account restore compose 2025-04-28 13:50:28 +02:00
95ea63db2f courrier multi account restore compose 2025-04-28 13:48:11 +02:00
d3974536ff courrier multi account restore compose 2025-04-28 13:39:01 +02:00
2657246c80 courrier multi account restore compose 2025-04-28 13:36:28 +02:00
426c9d62ee courrier multi account restore compose 2025-04-28 13:33:54 +02:00
0f9d84c6a9 courrier multi account restore compose 2025-04-28 13:23:33 +02:00
ab6e34c4fa courrier multi account restore compose 2025-04-28 13:21:04 +02:00
cc988b7b8c courrier multi account restore compose 2025-04-28 13:18:48 +02:00
5d284682a7 courrier multi account restore compose 2025-04-28 13:16:43 +02:00
a0790adf18 courrier multi account restore compose 2025-04-28 13:13:45 +02:00
fa4dc41ba9 courrier multi account restore compose 2025-04-28 13:11:54 +02:00
12383d53fd courrier multi account restore compose 2025-04-28 13:10:33 +02:00
542a535ce1 courrier multi account restore compose 2025-04-28 13:05:05 +02:00
b78590727b courrier multi account restore compose 2025-04-28 13:01:01 +02:00
c6c5fd45e9 courrier multi account restore compose 2025-04-28 12:54:58 +02:00
d73422ca48 courrier multi account restore compose 2025-04-28 12:54:46 +02:00
729151b6e4 courrier multi account restore compose 2025-04-28 12:54:16 +02:00
d07492f6bc courrier multi account restore compose 2025-04-28 12:53:47 +02:00
6ab432e0ff courrier multi account restore compose 2025-04-28 12:23:57 +02:00
4bfb348082 courrier multi account restore compose 2025-04-28 12:22:40 +02:00
cf0f724323 courrier multi account restore compose 2025-04-28 12:19:10 +02:00
6ac1f8c5af courrier multi account restore compose 2025-04-28 12:17:39 +02:00
f1adfca1d9 courrier multi account restore compose 2025-04-28 12:14:20 +02:00
d683458213 courrier multi account restore compose 2025-04-28 12:11:49 +02:00
6b37f0062d courrier multi account restore compose 2025-04-28 12:08:58 +02:00
3e6953a4a4 courrier multi account restore compose 2025-04-28 12:04:41 +02:00
ddad3a12ca courrier multi account restore compose 2025-04-28 11:59:09 +02:00
2e638960b8 courrier multi account restore compose 2025-04-28 11:55:02 +02:00
06bcaea4bf courrier multi account restore compose 2025-04-28 11:51:13 +02:00
62316bd5a6 courrier multi account restore compose 2025-04-28 11:48:05 +02:00
232130b6d3 courrier multi account restore compose 2025-04-28 11:45:59 +02:00
2e2eda4feb courrier multi account restore compose 2025-04-28 11:42:10 +02:00
6e0d9ea262 courrier multi account restore compose 2025-04-28 11:39:14 +02:00
9ba2190912 courrier multi account restore compose 2025-04-28 11:23:56 +02:00
136327cf8c courrier multi account restore compose 2025-04-28 11:20:22 +02:00
17ea40370a courrier multi account restore compose 2025-04-28 11:19:48 +02:00
3eec30d407 courrier multi account restore compose 2025-04-28 11:17:46 +02:00
353d36c52b courrier multi account restore compose 2025-04-28 11:12:12 +02:00
3d7a0c2559 courrier multi account restore compose 2025-04-28 11:09:10 +02:00
e5b7c6e34a courrier multi account restore compose 2025-04-28 11:08:01 +02:00
b088128856 courrier multi account restore compose 2025-04-28 11:01:36 +02:00
539ecd51b9 courrier multi account restore compose 2025-04-28 10:57:52 +02:00
e99d08fccf courrier multi account restore compose 2025-04-28 10:52:53 +02:00
003a7b354a courrier multi account restore compose 2025-04-28 10:46:08 +02:00
9147c03d4c courrier multi account restore compose 2025-04-28 10:44:41 +02:00
ef981a04ff courrier multi account restore compose 2025-04-28 10:24:55 +02:00
ca52beaca1 courrier multi account restore compose 2025-04-28 10:17:22 +02:00
f0a94f59c5 courrier multi account restore compose 2025-04-28 10:05:16 +02:00
76d40bb4e3 courrier multi account restore compose 2025-04-27 22:51:45 +02:00
a38942f0fd courrier multi account restore compose 2025-04-27 22:39:06 +02:00
bfd25774d3 courrier multi account restore compose 2025-04-27 22:34:18 +02:00
57ff7273e4 courrier multi account restore compose 2025-04-27 22:21:35 +02:00
d1c134da24 courrier multi account restore compose 2025-04-27 22:18:24 +02:00
d9ebe3d6fd courrier multi account restore compose 2025-04-27 22:14:08 +02:00
5ac1d6e760 courrier multi account restore compose 2025-04-27 22:10:51 +02:00
53dc77c6c6 courrier multi account restore compose 2025-04-27 22:08:01 +02:00
201db84677 courrier multi account restore compose 2025-04-27 22:06:34 +02:00
a82423469c courrier multi account restore compose 2025-04-27 22:04:50 +02:00
c49e3376d5 courrier multi account restore compose 2025-04-27 22:03:30 +02:00
d4b49a265d courrier multi account restore compose 2025-04-27 22:01:22 +02:00
cf2bf52357 courrier multi account restore compose 2025-04-27 21:55:03 +02:00
61e758f341 courrier multi account restore compose 2025-04-27 21:42:52 +02:00
7a0baaa3da courrier multi account restore compose 2025-04-27 21:41:27 +02:00
febd87f4e9 courrier multi account restore compose 2025-04-27 21:39:31 +02:00
038fe37dba courrier multi account restore compose 2025-04-27 21:35:47 +02:00
be9ba6e31e courrier multi account restore compose 2025-04-27 21:34:12 +02:00
1b68a983ea courrier multi account restore compose 2025-04-27 21:27:56 +02:00
0e2f728ceb courrier multi account restore compose 2025-04-27 20:05:40 +02:00
e4dbc6a5c9 courrier multi account 2025-04-27 18:51:56 +02:00
e647499bb6 courrier multi account 2025-04-27 18:48:21 +02:00
92440af7da courrier multi account 2025-04-27 18:46:23 +02:00
697e4d6d17 courrier multi account 2025-04-27 18:44:06 +02:00
27127ae99c courrier multi account 2025-04-27 18:42:51 +02:00
65d75a0213 courrier multi account 2025-04-27 18:39:33 +02:00
8137cd3701 courrier multi account 2025-04-27 18:35:57 +02:00
b98ed77327 courrier multi account 2025-04-27 18:31:21 +02:00
4b15e47059 courrier multi account 2025-04-27 18:31:10 +02:00
cf19b96bf8 courrier multi account 2025-04-27 18:29:25 +02:00
1d7cffad88 courrier multi account 2025-04-27 18:15:35 +02:00
9eaa77b525 courrier multi account 2025-04-27 18:13:10 +02:00
6516a726e9 courrier multi account 2025-04-27 18:10:31 +02:00
c787d6a1a5 courrier multi account 2025-04-27 18:02:22 +02:00
4d01953b7a courrier multi account 2025-04-27 17:35:34 +02:00
0d6c4a1be9 courrier multi account 2025-04-27 17:33:09 +02:00
24614f9596 courrier multi account 2025-04-27 17:23:09 +02:00
184d5180fa courrier multi account 2025-04-27 17:15:54 +02:00
b34bdee164 courrier multi account 2025-04-27 17:11:57 +02:00
a848ef6e06 courrier multi account 2025-04-27 17:08:18 +02:00
172e5b74c6 courrier multi account 2025-04-27 17:03:04 +02:00
7fa9f1ae76 courrier multi account 2025-04-27 17:01:48 +02:00
7276ca8861 courrier multi account 2025-04-27 16:58:01 +02:00
c2afb41ef2 courrier multi account 2025-04-27 16:56:53 +02:00
3d69698964 courrier multi account 2025-04-27 16:52:21 +02:00
9797e08533 courrier multi account 2025-04-27 16:39:33 +02:00
268a9faddd courrier multi account 2025-04-27 16:38:11 +02:00
9f9507b784 courrier multi account 2025-04-27 16:36:09 +02:00
b66f1adb3a courrier multi account 2025-04-27 16:14:50 +02:00
7eb6dd5b81 courrier multi account 2025-04-27 16:12:04 +02:00
1aa9608722 courrier multi account 2025-04-27 16:05:04 +02:00
b66081643f courrier multi account 2025-04-27 16:00:26 +02:00
cf1509540e courrier correct panel 2 scroll up 2025-04-27 15:35:21 +02:00
45f0da76c3 courrier correct panel 2 scroll up 2025-04-27 15:31:52 +02:00
d5ce9d5be1 courrier correct panel 2 scroll up 2025-04-27 15:30:27 +02:00
41559a4d66 courrier correct panel 2 scroll up 2025-04-27 15:27:24 +02:00
b2c788818f courrier correct panel 2 scroll up 2025-04-27 15:25:11 +02:00
ceca769cbf courrier correct panel 2 scroll up 2025-04-27 15:20:07 +02:00
a6bd553a27 courrier correct panel 2 scroll up 2025-04-27 15:16:27 +02:00
cb5d974fea courrier correct panel 2 scroll up 2025-04-27 15:11:42 +02:00
1cc8db2d20 courrier correct panel 2 scroll up 2025-04-27 15:07:13 +02:00
6f467fa5c6 courrier correct panel 2 scroll up 2025-04-27 15:00:11 +02:00
8819cbc081 courrier correct panel 2 scroll up 2025-04-27 14:57:43 +02:00
049e33ded6 courrier redis 2025-04-27 14:54:39 +02:00
1db445e6c9 courrier redis 2025-04-27 14:50:44 +02:00
d73442573d courrier redis 2025-04-27 14:45:33 +02:00
b2d356a6b2 courrier redis 2025-04-27 14:15:36 +02:00
5ea4d457fe courrier redis 2025-04-27 14:14:27 +02:00
3f415be882 courrier redis 2025-04-27 14:11:53 +02:00
b68993d68d courrier redis 2025-04-27 14:10:11 +02:00
0455ff7f7d courrier redis login 2025-04-27 14:05:14 +02:00
42746fe4f9 courrier redis login 2025-04-27 14:00:09 +02:00
4899bd093b courrier redis login 2025-04-27 13:55:33 +02:00
6c94b7f8f8 courrier redis login 2025-04-27 13:52:04 +02:00
04c0a089ac courrier redis 2025-04-27 13:43:55 +02:00
a3f8fd4082 courrier redis 2025-04-27 13:42:18 +02:00
bfb5441b3f courrier redis 2025-04-27 13:40:01 +02:00
973c6e54c1 courrier redis 2025-04-27 13:39:05 +02:00
de728b9139 courrier refactor rebuild 2 2025-04-27 12:06:54 +02:00
46d8220466 courrier refactor rebuild 2 2025-04-27 12:05:51 +02:00
45bbb8229f courrier refactor rebuild 2 2025-04-27 11:55:33 +02:00
80e5b3fdcf courrier refactor rebuild 2 2025-04-27 11:52:45 +02:00
2ba6a2717b courrier refactor rebuild 2 2025-04-27 11:50:36 +02:00
819955d24b courrier refactor rebuild 2 2025-04-27 11:48:57 +02:00
80d631eee0 courrier refactor rebuild 2 2025-04-27 11:47:05 +02:00
ec3b498233 courrier refactor rebuild 2 2025-04-27 11:43:40 +02:00
a1241a20fa courrier refactor rebuild 2 2025-04-27 11:39:12 +02:00
5ec5ad58df courrier refactor rebuild 2 2025-04-27 11:38:14 +02:00
9f82be44cc courrier refactor rebuild 2 2025-04-27 11:29:18 +02:00
c976b23a6c courrier refactor rebuild 2 2025-04-27 11:25:15 +02:00
e023d050d2 courrier refactor rebuild 2 2025-04-27 11:19:36 +02:00
4ef9268b86 courrier refactor rebuild 2 2025-04-27 11:03:34 +02:00
51a92f27dd courrier refactor rebuild 2 2025-04-27 10:47:10 +02:00
02c9e7054d courrier refactor rebuild 2 2025-04-27 10:40:43 +02:00
19cff1ce7c courrier refactor rebuild 2 2025-04-27 10:32:43 +02:00
a51a4c303d courrier refactor rebuild 2 2025-04-27 10:25:20 +02:00
b036530766 courrier refactor rebuild 2 2025-04-27 10:13:52 +02:00
76cc7b5bf2 courrier refactor rebuild 2 2025-04-27 10:11:20 +02:00
1c4b38ec8f courrier refactor rebuild 2 2025-04-27 10:02:33 +02:00
c993fe738e courrier refactor rebuild 2 2025-04-27 09:56:52 +02:00
3c738f179a courrier refactor rebuild preview 2025-04-27 00:44:45 +02:00
1d7f0b2b69 courrier refactor rebuild preview 2025-04-27 00:42:46 +02:00
bb97f9b364 courrier refactor rebuild preview 2025-04-27 00:40:58 +02:00
cb3e119a5d courrier refactor rebuild preview 2025-04-27 00:37:34 +02:00
88e03326af courrier refactor rebuild preview 2025-04-27 00:37:24 +02:00
034cf6da23 courrier refactor rebuild preview 2025-04-27 00:35:31 +02:00
bf7b02b903 courrier refactor rebuild preview 2025-04-27 00:34:02 +02:00
4ee8eb6662 courrier refactor rebuild preview 2025-04-27 00:31:37 +02:00
685d8b4a2f courrier refactor rebuild preview 2025-04-27 00:29:51 +02:00
9970c7b4c9 courrier refactor rebuild preview 2025-04-27 00:28:10 +02:00
3c4151335b courrier refactor rebuild preview 2025-04-27 00:24:24 +02:00
a2a088cf0f courrier refactor rebuild preview 2025-04-27 00:20:02 +02:00
c7f5e31b23 courrier refactor rebuild preview 2025-04-27 00:12:19 +02:00
3992718204 courrier refactor rebuild preview 2025-04-27 00:09:18 +02:00
ae1087f401 courrier refactor rebuild preview 2025-04-27 00:02:35 +02:00
c44ce9d41e courrier refactor 2025-04-26 23:58:55 +02:00
7fa72b5489 courrier refactor 2025-04-26 23:51:33 +02:00
87695eab03 courrier refactor 2025-04-26 23:49:09 +02:00
2beb44712c courrier refactor 2025-04-26 23:41:21 +02:00
7139d52100 courrier refactor 2025-04-26 23:34:49 +02:00
9befdd60c3 courrier refactor 2025-04-26 23:30:46 +02:00
4af36d63f9 courrier refactor 2025-04-26 23:25:19 +02:00
a30198cb2b courrier refactor 2025-04-26 23:18:51 +02:00
dcc2594195 courrier refactor 2025-04-26 23:11:58 +02:00
b056438814 courrier refactor 2025-04-26 23:06:39 +02:00
367b79bf0b courrier refactor 2025-04-26 22:59:41 +02:00
aefe858106 courrier refactor 2025-04-26 22:44:53 +02:00
6e66c2ac34 courrier clean 2$ 2025-04-26 22:08:44 +02:00
d09d8f0579 courrier clean 2$ 2025-04-26 22:05:41 +02:00
e95f078bbc courrier clean 2$ 2025-04-26 21:28:34 +02:00
88020ccfe5 courrier clean 2$ 2025-04-26 21:28:24 +02:00
96cf29b98b courrier clean 2$ 2025-04-26 21:25:39 +02:00
1add54f457 courrier clean 2$ 2025-04-26 21:19:52 +02:00
b14b03877c courrier clean 2$ 2025-04-26 21:18:29 +02:00
bc5809520d courrier clean 2$ 2025-04-26 21:16:03 +02:00
773c7759c4 courrier clean 2$ 2025-04-26 21:12:48 +02:00
fb0ab72675 courrier clean 2$ 2025-04-26 21:12:18 +02:00
1685946c07 courrier clean 2$ 2025-04-26 21:05:25 +02:00
e40df0ad87 courrier clean 2$ 2025-04-26 21:01:12 +02:00
91751b23ca courrier clean 2$ 2025-04-26 20:57:42 +02:00
ccf129f1a4 courrier clean 2$ 2025-04-26 20:51:29 +02:00
4bf9fcc165 courrier clean 2$ 2025-04-26 20:47:03 +02:00
ad9999fdd5 courrier clean 2$ 2025-04-26 20:44:11 +02:00
26e72f4f73 courrier clean 2$ 2025-04-26 20:38:34 +02:00
5686b9fb7d courrier clean 2$ 2025-04-26 20:31:21 +02:00
0bb4fac9f9 courrier clean 2$ 2025-04-26 20:19:08 +02:00
44f3b3072f courrier clean 2$ 2025-04-26 20:16:42 +02:00
104c0d9f94 courrier clean 2$ 2025-04-26 20:13:55 +02:00
d7bbb68b5e courrier clean 2$ 2025-04-26 20:09:09 +02:00
aeaa086b40 courrier clean 2$ 2025-04-26 19:40:07 +02:00
1663721c89 courrier clean 2$ 2025-04-26 19:37:32 +02:00
afc7f64027 courrier clean 2$ 2025-04-26 19:23:31 +02:00
4c9fcdeb29 courrier clean 2$ 2025-04-26 19:20:52 +02:00
3e6b8cae1f courrier clean 2$ 2025-04-26 18:46:21 +02:00
e3b946f7e9 courrier clean 2 2025-04-26 18:24:28 +02:00
ddf72cc4f0 courrier clean 2 2025-04-26 18:22:02 +02:00
8137b42c5f courrier clean 2 2025-04-26 18:19:22 +02:00
c0153a2aef courrier clean 2 2025-04-26 18:12:07 +02:00
d607cc54bb courrier clean 2 2025-04-26 15:03:46 +02:00
24bafdcce4 courrier clean 2 2025-04-26 14:54:26 +02:00
051bcb08a4 courrier clean 2 2025-04-26 14:52:19 +02:00
97fb21a632 courrier clean 2 2025-04-26 14:49:20 +02:00
626b35bb40 courrier clean 2 2025-04-26 14:45:14 +02:00
090e703214 courrier clean 2 2025-04-26 14:41:40 +02:00
71cc875f57 courrier clean 2 2025-04-26 14:39:04 +02:00
34d2aed721 courrier clean 2 2025-04-26 14:35:20 +02:00
9fba1ac1c3 courrier clean 2 2025-04-26 14:31:55 +02:00
f4112f3160 courrier clean 2 2025-04-26 14:28:56 +02:00
6f38d53335 courrier clean 2 2025-04-26 14:27:09 +02:00
59f9afe9fe courrier clean 2 2025-04-26 14:23:32 +02:00
0c5437a24f courrier clean 2 2025-04-26 14:17:44 +02:00
e3791fa583 courrier clean 2 2025-04-26 13:51:07 +02:00
b0387982bf courrier clean 2 2025-04-26 13:34:15 +02:00
ad1723cce9 courrier clean 2 2025-04-26 13:28:53 +02:00
a9dc2da891 courrier clean 2 2025-04-26 13:02:31 +02:00
3f5e3097d3 courrier clean 2 2025-04-26 12:57:45 +02:00
2c98417f50 courrier clean 2 2025-04-26 12:55:18 +02:00
10b08b5043 courrier clean 2 2025-04-26 12:47:44 +02:00
f4a77ecd25 courrier clean 2 2025-04-26 12:16:18 +02:00
4db3140ece courrier clean 2 2025-04-26 12:12:43 +02:00
c325d3cdf7 courrier clean 2 2025-04-26 12:03:36 +02:00
195f8b7115 courrier clean 2 2025-04-26 11:58:58 +02:00
6bacfa28da courrier clean 2 2025-04-26 11:52:42 +02:00
638a2ee895 courrier clean 2 2025-04-26 11:51:28 +02:00
f117d66626 courrier clean 2 2025-04-26 11:46:03 +02:00
7820663c78 courrier clean 2025-04-26 11:41:20 +02:00
e3db0a2ae1 courrier clean 2025-04-26 11:36:35 +02:00
b58539aeaa panel 2 courier api restore 2025-04-26 11:27:01 +02:00
180 changed files with 20788 additions and 7087 deletions

BIN
.DS_Store vendored

Binary file not shown.

23
.env
View File

@ -27,7 +27,7 @@ NEXT_PUBLIC_IFRAME_LEARN_URL=https://apprendre.slm-lab.net
NEXT_PUBLIC_IFRAME_PAROLE_URL=https://parole.slm-lab.net/channel/City
NEXT_PUBLIC_IFRAME_CHAPTER_URL=https://chapitre.slm-lab.net
NEXT_PUBLIC_IFRAME_AGILITY_URL=https://agilite.slm-lab.net/oidc/login
NEXT_PUBLIC_IFRAME_ARTLAB_URL=https://artlab.slm-lab.net
NEXT_PUBLIC_IFRAME_DESIGN_URL=https://artlab.slm-lab.net
NEXT_PUBLIC_IFRAME_GITE_URL=https://gite.slm-lab.net/user/oauth2/cube
NEXT_PUBLIC_IFRAME_CALCULATION_URL=https://calcul.slm-lab.net
NEXT_PUBLIC_IFRAME_MEDIATIONS_URL=https://connect.slm-lab.net/realms/cercle/protocol/openid-connect/auth?client_id=mediations.slm-lab.net&redirect_uri=https%3A%2F%2Fmediations.slm-lab.net%2F%3Fopenid_mode%3Dtrue&scope=openid%20profile%20email&response_type=code
@ -45,7 +45,7 @@ NEXT_PUBLIC_IFRAME_ANNOUNCEMENT_URL=https://espace.slm-lab.net/apps/announcement
NEXT_PUBLIC_IFRAME_HEALTHVIEW_URL=https://espace.slm-lab.net/apps/health/?embedMode=true&hideNavigation=true
NEXT_PUBLIC_IFRAME_MISSIONVIEW_URL=https://connect.slm-lab.net/realms/cercle/protocol/openid-connect/auth?response_type=code&scope=openid&client_id=page.slm-lab.net&state=f72528f6756bc132e76dd258691b71cf&redirect_uri=https%3A%2F%2Fpage.slm-lab.net%2Fwp-admin%2F
NEXT_PUBLIC_IFRAME_USERSVIEW_URL=https://example.com/users-view
NEXT_PUBLIC_IFRAME_THEMESSAGE_URL=https://lemessage.slm-lab.net/admin/
NEXT_PUBLIC_IFRAME_THEMESSAGE_URL=https://lemessage.slm-lab.net/auth/oidc
NEXT_PUBLIC_IFRAME_AI_ASSISTANT_URL=https://alma.slm-lab.net
ROCKET_CHAT_TOKEN=w91TYgkH-Z67Oz72usYdkW5TZLLRwnre7qyAhp7aHJB
@ -77,3 +77,22 @@ IMAP_HOST=mail.infomaniak.com
IMAP_PORT=993
NEWS_API_URL="http://172.16.0.104:8000"
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=mySecretPassword
MICROSOFT_CLIENT_ID="afaffea5-4e10-462a-aa64-e73baf642c57"
MICROSOFT_CLIENT_SECRET="eIx8Q~N3ZnXTjTsVM3ECZio4G7t.BO6AYlD1-b2h"
MICROSOFT_REDIRECT_URI="https://lab.slm-lab.net/ms"
MICROSOFT_TENANT_ID="cb4281a9-4a3e-4ff5-9a85-8425dd04e2b2"
# Reduce session size to prevent chunking
NEXTAUTH_JWT_CALLBACK_STORE_SESSION_DATA=false
NEXTAUTH_SESSION_STORE_SESSION_TOKEN=false
NEXTAUTH_JWT_STORE_RAW_TOKEN=false
# Cookie security and sharing
NEXTAUTH_COOKIE_DOMAIN=.slm-lab.net
NEXTAUTH_URL=https://lab.slm-lab.net
NEXTAUTH_COOKIE_SECURE=true
NEXTAUTH_COOKIE_SAMESITE=none

139
DEPRECATED_FUNCTIONS.md Normal file
View File

@ -0,0 +1,139 @@
# Deprecated Functions and Files
This document lists functions and files that have been deprecated and should not be used in new code.
## Deprecated Files
### 1. `lib/email-formatter.ts` (REMOVED)
- **Status**: Removed
- **Replacement**: Use `lib/utils/email-utils.ts` instead
- **Reason**: Consolidated email formatting to a single source of truth
### 2. `lib/mail-parser-wrapper.ts` (REMOVED)
- **Status**: Removed
- **Replacement**: Use functions from `lib/utils/email-utils.ts` instead
- **Reason**: Consolidated email formatting and sanitization to a single source of truth
### 3. `lib/email-parser.ts` (REMOVED)
- **Status**: Removed
- **Replacement**: Use `lib/server/email-parser.ts` for parsing and `lib/utils/email-utils.ts` for sanitization
- **Reason**: Consolidated email parsing and formatting to dedicated files
### 4. `lib/compose-mime-decoder.ts` (REMOVED)
- **Status**: Removed
- **Replacement**: Use `decodeComposeContent` and `encodeComposeContent` functions from `lib/utils/email-utils.ts`
- **Reason**: Consolidated MIME handling into the centralized formatter
## Deprecated Functions
### 1. `formatEmailForReplyOrForward` in `lib/services/email-service.ts` (REMOVED)
- **Status**: Removed
- **Replacement**: Use `formatEmailForReplyOrForward` from `lib/utils/email-utils.ts`
- **Reason**: Consolidated email formatting to a single source of truth
### 2. `formatSubject` in `lib/services/email-service.ts` (REMOVED)
- **Status**: Removed
- **Replacement**: None specific, handled by centralized formatter
- **Reason**: Internal function of the email formatter
### 3. `createQuoteHeader` in `lib/services/email-service.ts` (REMOVED)
- **Status**: Removed
- **Replacement**: None specific, handled by centralized formatter
- **Reason**: Internal function of the email formatter
## Centralized Email Formatting
All email formatting is now handled by the centralized formatter in `lib/utils/email-utils.ts`. This file contains:
1. `formatForwardedEmail`: Format emails for forwarding
2. `formatReplyEmail`: Format emails for replying or replying to all
3. `formatEmailForReplyOrForward`: Compatibility function that maps to the above two
4. `sanitizeHtml`: Safely sanitize HTML content while preserving direction attributes
Use these functions for all email formatting needs.
## Email Parsing and Processing Functions
### 1. `splitEmailHeadersAndBody` (REMOVED)
- **Location**: Removed
- **Reason**: Email parsing has been centralized in `lib/server/email-parser.ts` and the API endpoint.
- **Replacement**: Use the `parseEmail` function from `lib/server/email-parser.ts` which provides a comprehensive parsing solution.
### 2. `getReplyBody`
- **Location**: `app/courrier/page.tsx`
- **Reason**: Should use the `ReplyContent` component directly.
- **Replacement**: Use `<ReplyContent email={email} type={type} />` directly.
- **Status**: Currently marked with `@deprecated` comment, no direct usages found.
### 3. `generateEmailPreview`
- **Location**: `app/courrier/page.tsx`
- **Reason**: Should use the `EmailPreview` component directly.
- **Replacement**: Use `<EmailPreview email={email} />` directly.
- **Status**: Currently marked with `@deprecated` comment, no usages found.
### 4. `cleanHtml` (REMOVED)
- **Location**: Removed from `lib/server/email-parser.ts`
- **Reason**: HTML sanitization has been consolidated in `lib/utils/email-utils.ts`.
- **Replacement**: Use `sanitizeHtml` from `lib/utils/email-utils.ts`.
### 5. `processHtml` (REMOVED)
- **Location**: Removed from `app/api/parse-email/route.ts`
- **Reason**: HTML processing has been consolidated in `lib/utils/email-utils.ts`.
- **Replacement**: Use `sanitizeHtml` from `lib/utils/email-utils.ts`.
## Deprecated API Routes
### 1. `app/api/mail/[id]/route.ts` (REMOVED)
- **Status**: Removed
- **Replacement**: Use `app/api/courrier/[id]/route.ts` instead.
### 2. `app/api/mail/route.ts` (REMOVED)
- **Status**: Removed
- **Replacement**: Use `app/api/courrier/route.ts` instead.
### 3. `app/api/mail/send/route.ts` (REMOVED)
- **Status**: Removed
- **Replacement**: Use `app/api/courrier/send/route.ts` instead.
## Deprecated Components
### ComposeEmail (components/ComposeEmail.tsx) (REMOVED)
**Status:** Removed
**Replacement:** Use `components/email/ComposeEmail.tsx` instead
This component has been removed in favor of the more modular and better structured version in the email directory. The newer version has the following improvements:
- Better separation between user message and quoted content in replies/forwards
- Improved styling and visual hierarchy
- Support for RTL/LTR text direction toggling
- More modern UI using Card components instead of a modal
- Better state management for email composition
A compatibility layer has been added to the new component to ensure backward compatibility with existing code that uses the old component. This allows for a smooth transition without breaking changes.
## Migration Plan
### Phase 1: Deprecation (Completed)
- Mark all deprecated functions with `@deprecated` comments
- Add console warnings to deprecated functions
- Document alternatives
### Phase 2: Removal (Completed)
- Remove deprecated files: `lib/email-parser.ts` and `lib/mail-parser-wrapper.ts`
- Consolidate all email formatting in `lib/utils/email-utils.ts`
- All email parsing now in `lib/server/email-parser.ts`
- Update documentation to point to the centralized utilities
## Server-Client Code Separation
### Server-side imports in client components
- **Status**: Fixed in November 2023
- **Issue**: Server-only modules like ImapFlow were being imported directly in client components, causing build errors with messages like "Module not found: Can't resolve 'tls'"
- **Fix**:
1. Added 'use server' directive to server-only modules
2. Created client-safe interfaces in client components
3. Added server actions for email operations that need server capabilities
4. Refactored ComposeEmail component to avoid direct server imports
This architecture ensures a clean separation between server and client code, which is essential for Next.js applications, particularly with the App Router. It prevents Node.js-specific modules from being bundled into client-side JavaScript.

59
README.md Normal file
View File

@ -0,0 +1,59 @@
# NeahFront9
This project is a modern Next.js dashboard application with various widgets and components.
## Code Refactoring
The codebase is being systematically refactored to improve:
1. **Code Organization**: Moving from monolithic components to modular, reusable ones
2. **Maintainability**: Implementing custom hooks for data fetching and state management
3. **Performance**: Reducing duplicate code and optimizing renders
4. **Consistency**: Creating unified utilities for common operations
### Completed Refactoring
The following refactoring tasks have been completed:
#### Utility Modules
- **Status Utilities** (`lib/utils/status-utils.ts`): Centralized task status color and label handling
- **Date Utilities** (`lib/utils/date-utils.ts`): Common date validation and formatting functions
#### Custom Hooks
- **Task Hook** (`hooks/use-tasks.ts`): Reusable hook for fetching and managing task data
- **Calendar Events Hook** (`hooks/use-calendar-events.ts`): Reusable hook for calendar event handling
#### Updated Components
- **Duties/Flow Component** (`components/flow.tsx`): Simplified to use the custom task hook
- **Calendar Component** (`components/calendar.tsx`): Refactored to use the custom calendar events hook
### In Progress
- More components will be refactored to follow the same patterns
- State management improvements across the application
- Better UI responsiveness and accessibility
## API Documentation
A documentation endpoint has been created to track code updates:
```
GET /api/code-updates
```
This returns a JSON object with details of all refactoring changes made to the codebase.
## Development
To run the development server:
```bash
npm run dev
# or
yarn dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.

BIN
app/.DS_Store vendored

Binary file not shown.

View File

@ -1,30 +1,70 @@
import { notFound } from 'next/navigation'
import { getServerSession } from 'next-auth/next';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import ResponsiveIframe from '@/app/components/responsive-iframe';
import { redirect } from 'next/navigation';
const menuItems = {
board: "https://example.com/board",
chapter: "https://example.com/chapter",
flow: "https://example.com/flow",
design: "https://example.com/design",
// Use environment variables for real service URLs
const menuItems: Record<string, string> = {
parole: process.env.NEXT_PUBLIC_IFRAME_PAROLE_URL || '',
alma: process.env.NEXT_PUBLIC_IFRAME_AI_ASSISTANT_URL || '',
crm: process.env.NEXT_PUBLIC_IFRAME_MEDIATIONS_URL || '',
vision: process.env.NEXT_PUBLIC_IFRAME_CONFERENCE_URL || '',
showcase: process.env.NEXT_PUBLIC_IFRAME_SHOWCASE_URL || '',
agilite: process.env.NEXT_PUBLIC_IFRAME_AGILITY_URL || '',
dossiers: process.env.NEXT_PUBLIC_IFRAME_DRIVE_URL || '',
'the-message': process.env.NEXT_PUBLIC_IFRAME_THEMESSAGE_URL || '',
qg: process.env.NEXT_PUBLIC_IFRAME_MISSIONVIEW_URL || '',
// Use environment variables for these items too
board: process.env.NEXT_PUBLIC_IFRAME_BOARD_URL || '',
chapter: process.env.NEXT_PUBLIC_IFRAME_CHAPTER_URL || '',
flow: process.env.NEXT_PUBLIC_IFRAME_FLOW_URL || '',
design: process.env.NEXT_PUBLIC_IFRAME_DESIGN_URL || '',
artlab: process.env.NEXT_PUBLIC_IFRAME_DESIGN_URL || '',
// External URLs
gitlab: "https://gitlab.com",
crm: "https://example.com/crm",
missions: "https://example.com/missions"
missions: process.env.NEXT_PUBLIC_IFRAME_MISSIONSBOARD_URL || ''
}
export default async function SectionPage({ params }: { params: { section: string } }) {
const { section } = params;
const iframeUrl = menuItems[section as keyof typeof menuItems]
// Using a different approach for metadata that doesn't directly access params.section
export async function generateMetadata({ params }: { params: { section: string } }) {
// Safely handle params
const paramsObj = await Promise.resolve(params);
const sectionName = paramsObj?.section || '';
if (!iframeUrl) {
notFound()
// Capitalize first letter
const title = sectionName ?
sectionName.charAt(0).toUpperCase() + sectionName.slice(1) :
'Section';
return { title };
}
export default async function SectionPage(props: { params: { section: string } }) {
// Ensure authentication first
const session = await getServerSession(authOptions);
if (!session) {
redirect("/signin");
}
// Safely extract section using await Promise.resolve
const params = await Promise.resolve(props.params);
const section = params?.section || '';
// Check if section exists in our menu items
if (!section || !(section in menuItems)) {
notFound();
}
// Get the direct URL for this section
const directUrl = menuItems[section];
return (
<div className="w-full h-[calc(100vh-8rem)]">
<iframe
src={iframeUrl}
<ResponsiveIframe
src={directUrl}
className="w-full h-full border-none"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
allowFullScreen={true}
/>
</div>
)

View File

@ -1,7 +1,7 @@
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { redirect } from "next/navigation";
import { ResponsiveIframe } from "@/app/components/responsive-iframe";
import ResponsiveIframe from "@/app/components/responsive-iframe";
export default async function Page() {
const session = await getServerSession(authOptions);
@ -15,7 +15,7 @@ export default async function Page() {
<div className="w-full h-full px-4 pt-12 pb-4">
<ResponsiveIframe
src={process.env.NEXT_PUBLIC_IFRAME_AGILITY_URL || ''}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen={true}
/>
</div>
</main>

View File

@ -1,7 +1,7 @@
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { redirect } from "next/navigation";
import { ResponsiveIframe } from "@/app/components/responsive-iframe";
import ResponsiveIframe from "@/app/components/responsive-iframe";
export default async function Page() {
const session = await getServerSession(authOptions);
@ -15,7 +15,7 @@ export default async function Page() {
<div className="w-full h-full px-4 pt-12 pb-4">
<ResponsiveIframe
src={process.env.NEXT_PUBLIC_IFRAME_AI_ASSISTANT_URL || ''}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen={true}
/>
</div>
</main>

View File

@ -0,0 +1,133 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { prisma } from '@/lib/prisma';
import { getRedisClient } from '@/lib/redis';
// This is an admin-only route to restore email credentials from Redis to the database
export async function GET(request: Request) {
try {
// Authenticate user
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Only allow authorized users
if (!session.user.role.includes('admin')) {
return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
}
const redis = getRedisClient();
// Get all email credential keys
const keys = await redis.keys('email:credentials:*');
console.log(`Found ${keys.length} credential records in Redis`);
const results = {
total: keys.length,
processed: 0,
success: 0,
errors: [] as string[]
};
// Process each key
for (const key of keys) {
results.processed++;
try {
// Extract user ID from key
const userId = key.split(':')[2];
if (!userId) {
results.errors.push(`Invalid key format: ${key}`);
continue;
}
// Get credentials from Redis
const credStr = await redis.get(key);
if (!credStr) {
results.errors.push(`No data found for key: ${key}`);
continue;
}
// Parse credentials
const creds = JSON.parse(credStr);
console.log(`Processing credentials for user ${userId}`, {
email: creds.email,
host: creds.host
});
// Check if user exists
const userExists = await prisma.user.findUnique({
where: { id: userId },
select: { id: true }
});
if (!userExists) {
// Create dummy user if needed (this is optional and might not be appropriate in all cases)
// Remove or modify this section if you don't want to create placeholder users
console.log(`User ${userId} not found, creating placeholder`);
await prisma.user.create({
data: {
id: userId,
email: creds.email || 'placeholder@example.com',
password: 'PLACEHOLDER_HASH_CHANGE_THIS', // You should set a proper password
// Add any other required fields
}
});
console.log(`Created placeholder user ${userId}`);
}
// Upsert credentials in database
await prisma.mailCredentials.upsert({
where: { userId },
update: {
email: creds.email,
password: creds.encryptedPassword || 'encrypted_placeholder',
host: creds.host,
port: creds.port,
// Optional fields
...(creds.secure !== undefined && { secure: creds.secure }),
...(creds.smtp_host && { smtp_host: creds.smtp_host }),
...(creds.smtp_port && { smtp_port: creds.smtp_port }),
...(creds.smtp_secure !== undefined && { smtp_secure: creds.smtp_secure }),
...(creds.display_name && { display_name: creds.display_name }),
...(creds.color && { color: creds.color })
},
create: {
userId,
email: creds.email,
password: creds.encryptedPassword || 'encrypted_placeholder',
host: creds.host,
port: creds.port,
// Optional fields
...(creds.secure !== undefined && { secure: creds.secure }),
...(creds.smtp_host && { smtp_host: creds.smtp_host }),
...(creds.smtp_port && { smtp_port: creds.smtp_port }),
...(creds.smtp_secure !== undefined && { smtp_secure: creds.smtp_secure }),
...(creds.display_name && { display_name: creds.display_name }),
...(creds.color && { color: creds.color })
}
});
results.success++;
console.log(`Successfully restored credentials for user ${userId}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
results.errors.push(`Error processing ${key}: ${message}`);
console.error(`Error processing ${key}:`, error);
}
}
return NextResponse.json({
message: 'Credential restoration process completed',
results
});
} catch (error) {
console.error('Error in restore credentials route:', error);
return NextResponse.json(
{ error: 'Failed to restore credentials', details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,65 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { getRedisClient } from '@/lib/redis';
// This route just views Redis email credentials without making any changes
export async function GET(request: Request) {
try {
// Authenticate user
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const redis = getRedisClient();
// Get all email credential keys
const keys = await redis.keys('email:credentials:*');
console.log(`Found ${keys.length} credential records in Redis`);
const credentials = [];
// Process each key
for (const key of keys) {
try {
// Extract user ID from key
const userId = key.split(':')[2];
// Get credentials from Redis
const credStr = await redis.get(key);
if (!credStr) continue;
// Parse credentials
const creds = JSON.parse(credStr);
// Add to results (remove sensitive data)
credentials.push({
userId,
email: creds.email,
host: creds.host,
port: creds.port,
hasPassword: !!creds.encryptedPassword,
// Include other non-sensitive fields
smtp_host: creds.smtp_host,
smtp_port: creds.smtp_port,
display_name: creds.display_name,
color: creds.color
});
} catch (error) {
console.error(`Error processing ${key}:`, error);
}
}
return NextResponse.json({
count: credentials.length,
credentials
});
} catch (error) {
console.error('Error viewing Redis credentials:', error);
return NextResponse.json(
{ error: 'Failed to view credentials', details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}

View File

@ -1,12 +1,11 @@
import NextAuth, { NextAuthOptions } from "next-auth";
import KeycloakProvider from "next-auth/providers/keycloak";
import { jwtDecode } from "jwt-decode";
// Define Keycloak profile type
interface KeycloakProfile {
sub: string;
email?: string;
name?: string;
roles?: string[];
preferred_username?: string;
given_name?: string;
family_name?: string;
@ -15,13 +14,18 @@ interface KeycloakProfile {
};
}
interface DecodedToken {
realm_access?: {
roles: string[];
};
[key: string]: any;
// Define custom profile type
interface CustomProfile {
id: string;
name?: string | null;
email?: string | null;
username: string;
first_name: string;
last_name: string;
role: string[];
}
// Declare module augmentation for NextAuth types
declare module "next-auth" {
interface Session {
user: {
@ -33,7 +37,6 @@ declare module "next-auth" {
first_name: string;
last_name: string;
role: string[];
nextcloudInitialized?: boolean;
};
accessToken?: string;
}
@ -42,162 +45,127 @@ declare module "next-auth" {
sub?: string;
accessToken?: string;
refreshToken?: string;
accessTokenExpires?: number;
role?: string[];
username?: string;
first_name?: string;
last_name?: string;
error?: string;
email?: string | null;
name?: string | null;
}
}
function getRequiredEnvVar(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
async function refreshAccessToken(token: JWT) {
try {
const response = await fetch(`${process.env.KEYCLOAK_ISSUER}/protocol/openid-connect/token`, {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: process.env.KEYCLOAK_CLIENT_ID!,
client_secret: process.env.KEYCLOAK_CLIENT_SECRET!,
grant_type: "refresh_token",
refresh_token: token.refreshToken,
}),
method: "POST",
});
const refreshedTokens = await response.json();
if (!response.ok) {
throw refreshedTokens;
}
return {
...token,
accessToken: refreshedTokens.access_token,
refreshToken: refreshedTokens.refresh_token ?? token.refreshToken,
accessTokenExpires: Date.now() + refreshedTokens.expires_in * 1000,
};
} catch (error) {
console.error("Error refreshing access token:", error);
return {
...token,
error: "RefreshAccessTokenError",
};
}
}
// Simple, minimal implementation - NO REFRESH TOKEN LOGIC
export const authOptions: NextAuthOptions = {
providers: [
KeycloakProvider({
clientId: getRequiredEnvVar("KEYCLOAK_CLIENT_ID"),
clientSecret: getRequiredEnvVar("KEYCLOAK_CLIENT_SECRET"),
issuer: getRequiredEnvVar("KEYCLOAK_ISSUER"),
authorization: {
params: {
scope: "openid profile email roles"
}
},
profile(profile) {
console.log('Keycloak profile callback:', {
rawProfile: profile,
rawRoles: profile.roles,
realmAccess: profile.realm_access,
groups: profile.groups
});
// Get roles from realm_access
const roles = profile.realm_access?.roles || [];
console.log('Profile callback raw roles:', roles);
// Clean up roles by removing ROLE_ prefix and converting to lowercase
const cleanRoles = roles.map((role: string) =>
role.replace(/^ROLE_/, '').toLowerCase()
);
console.log('Profile callback cleaned roles:', cleanRoles);
clientId: process.env.KEYCLOAK_CLIENT_ID || "",
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET || "",
issuer: process.env.KEYCLOAK_ISSUER || "",
profile(profile: any) {
// Just return a simple profile with required fields
return {
id: profile.sub,
name: profile.name ?? profile.preferred_username,
name: profile.name || profile.preferred_username,
email: profile.email,
first_name: profile.given_name ?? '',
last_name: profile.family_name ?? '',
username: profile.preferred_username ?? profile.email?.split('@')[0] ?? '',
role: cleanRoles,
}
},
image: null,
username: profile.preferred_username || profile.email?.split('@')[0] || '',
first_name: profile.given_name || '',
last_name: profile.family_name || '',
role: ['user'],
// Store raw profile data for later processing
raw_profile: profile
};
}
}),
],
session: {
strategy: "jwt",
maxAge: 30 * 24 * 60 * 60, // 30 days
maxAge: 8 * 60 * 60, // 8 hours
},
callbacks: {
async jwt({ token, account, profile }) {
if (account && profile) {
const keycloakProfile = profile as KeycloakProfile;
const roles = keycloakProfile.realm_access?.roles || [];
const cleanRoles = roles.map((role: string) =>
role.replace(/^ROLE_/, '').toLowerCase()
);
token.accessToken = account.access_token ?? '';
token.refreshToken = account.refresh_token ?? '';
token.accessTokenExpires = account.expires_at ?? 0;
token.sub = keycloakProfile.sub;
token.role = cleanRoles;
token.username = keycloakProfile.preferred_username ?? '';
token.first_name = keycloakProfile.given_name ?? '';
token.last_name = keycloakProfile.family_name ?? '';
} else if (token.accessToken) {
try {
const decoded = jwtDecode<DecodedToken>(token.accessToken);
if (decoded.realm_access?.roles) {
const roles = decoded.realm_access.roles;
const cleanRoles = roles.map((role: string) =>
role.replace(/^ROLE_/, '').toLowerCase()
);
token.role = cleanRoles;
async jwt({ token, account, profile, user }: any) {
// Initial sign in
if (account && account.access_token) {
token.accessToken = account.access_token;
token.refreshToken = account.refresh_token;
// Process the raw profile data if available
if (user && user.raw_profile) {
const rawProfile = user.raw_profile;
// Extract roles from all possible sources
let roles: string[] = [];
// Get roles from realm_access
if (rawProfile.realm_access && Array.isArray(rawProfile.realm_access.roles)) {
roles = roles.concat(rawProfile.realm_access.roles);
}
} catch (error) {
console.error('Error decoding token:', error);
// Get roles from resource_access
if (rawProfile.resource_access) {
const clientId = process.env.KEYCLOAK_CLIENT_ID;
if (clientId &&
rawProfile.resource_access[clientId] &&
Array.isArray(rawProfile.resource_access[clientId].roles)) {
roles = roles.concat(rawProfile.resource_access[clientId].roles);
}
// Also check resource_access roles under 'account'
if (rawProfile.resource_access.account &&
Array.isArray(rawProfile.resource_access.account.roles)) {
roles = roles.concat(rawProfile.resource_access.account.roles);
}
}
// Clean up roles and convert to lowercase
const cleanedRoles = roles
.filter(Boolean)
.map(role => role.toLowerCase());
// Always ensure user has basic user role
const finalRoles = [...new Set([...cleanedRoles, 'user'])];
// Map Keycloak roles to application roles
token.role = mapToApplicationRoles(finalRoles);
} else if (user && user.role) {
token.role = Array.isArray(user.role) ? user.role : [user.role];
} else {
// Default roles if no profile data available
token.role = ['user'];
}
// Store user information
if (user) {
token.username = user.username || user.name || '';
token.first_name = user.first_name || '';
token.last_name = user.last_name || '';
}
}
// Token exists but no roles, add default user role
else if (token && !token.role) {
token.role = ['user'];
}
return token;
},
async session({ session, token }: any) {
// Pass necessary info to the session
session.accessToken = token.accessToken;
if (session.user) {
session.user.id = token.sub || "";
// Ensure roles are passed to the session
if (token.role && Array.isArray(token.role)) {
session.user.role = token.role;
session.user.username = token.username || '';
session.user.first_name = token.first_name || '';
session.user.last_name = token.last_name || '';
} else {
// Fallback roles
session.user.role = ["user"];
session.user.username = '';
session.user.first_name = '';
session.user.last_name = '';
}
}
if (Date.now() < (token.accessTokenExpires as number) * 1000) {
return token;
}
return refreshAccessToken(token);
},
async session({ session, token }) {
if (token.error) {
throw new Error(token.error);
}
const userRoles = Array.isArray(token.role) ? token.role : [];
session.user = {
id: token.sub ?? '',
email: token.email ?? null,
name: token.name ?? null,
image: null,
username: token.username ?? '',
first_name: token.first_name ?? '',
last_name: token.last_name ?? '',
role: userRoles,
nextcloudInitialized: false,
};
session.accessToken = token.accessToken;
return session;
}
},
@ -205,22 +173,57 @@ export const authOptions: NextAuthOptions = {
signIn: '/signin',
error: '/signin',
},
cookies: {
sessionToken: {
name: 'next-auth.session-token',
options: {
httpOnly: true,
sameSite: 'none',
path: '/',
secure: true,
},
},
},
debug: process.env.NODE_ENV === 'development',
};
/**
* Maps Keycloak roles to application-specific roles
*/
function mapToApplicationRoles(keycloakRoles: string[]): string[] {
const mappings: Record<string, string[]> = {
// Map Keycloak roles to your application's role names
'admin': ['admin', 'dataintelligence', 'coding', 'expression', 'mediation'],
'owner': ['admin', 'dataintelligence', 'coding', 'expression', 'mediation'],
'cercle-admin': ['admin', 'dataintelligence', 'coding', 'expression', 'mediation'],
'manager': ['dataintelligence', 'coding', 'expression', 'mediation'],
'developer': ['coding', 'dataintelligence'],
'data-scientist': ['dataintelligence'],
'designer': ['expression'],
'writer': ['expression'],
'mediator': ['mediation'],
// Default access roles from Keycloak
'default-roles-cercle': ['user'],
'uma_authorization': ['user'],
'offline_access': ['user'],
// Add more mappings as needed
};
// Map each role and flatten the result
let appRoles: string[] = ['user']; // Always include 'user' role
for (const role of keycloakRoles) {
const mappedRoles = mappings[role.toLowerCase()];
if (mappedRoles) {
appRoles = [...appRoles, ...mappedRoles];
}
}
// Remove duplicates and return
return [...new Set(appRoles)];
}
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
interface JWT {
accessToken: string;
refreshToken: string;
accessTokenExpires: number;
}
interface Profile {
sub?: string;
email?: string;
name?: string;
roles?: string[];
}

View File

@ -0,0 +1,308 @@
import NextAuth, { NextAuthOptions } from "next-auth";
import KeycloakProvider from "next-auth/providers/keycloak";
import { jwtDecode } from "jwt-decode";
interface KeycloakProfile {
sub: string;
email?: string;
name?: string;
roles?: string[];
preferred_username?: string;
given_name?: string;
family_name?: string;
realm_access?: {
roles: string[];
};
}
interface DecodedToken {
realm_access?: {
roles: string[];
};
[key: string]: any;
}
declare module "next-auth" {
interface Session {
user: {
id: string;
name?: string | null;
email?: string | null;
image?: string | null;
username: string;
first_name: string;
last_name: string;
role: string[];
nextcloudInitialized?: boolean;
};
accessToken?: string;
}
interface JWT {
sub?: string;
accessToken?: string;
refreshToken?: string;
accessTokenExpires?: number;
role?: string[];
username?: string;
first_name?: string;
last_name?: string;
error?: string;
email?: string | null;
name?: string | null;
}
}
function getRequiredEnvVar(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
async function refreshAccessToken(token: JWT) {
try {
console.log('Attempting to refresh access token');
const response = await fetch(`${process.env.KEYCLOAK_ISSUER}/protocol/openid-connect/token`, {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: process.env.KEYCLOAK_CLIENT_ID!,
client_secret: process.env.KEYCLOAK_CLIENT_SECRET!,
grant_type: "refresh_token",
refresh_token: token.refreshToken,
}),
method: "POST",
});
const refreshedTokens = await response.json();
if (!response.ok) {
console.error('Token refresh failed with status:', response.status);
console.error('Error response:', refreshedTokens);
throw refreshedTokens;
}
console.log('Token refresh successful');
return {
...token,
accessToken: refreshedTokens.access_token,
refreshToken: refreshedTokens.refresh_token ?? token.refreshToken,
accessTokenExpires: Date.now() + refreshedTokens.expires_in * 1000,
error: undefined, // Clear any previous errors
};
} catch (error) {
console.error("Error refreshing access token:", error);
return {
...token,
error: "RefreshAccessTokenError",
};
}
}
export const authOptions: NextAuthOptions = {
providers: [
KeycloakProvider({
clientId: getRequiredEnvVar("KEYCLOAK_CLIENT_ID"),
clientSecret: getRequiredEnvVar("KEYCLOAK_CLIENT_SECRET"),
issuer: getRequiredEnvVar("KEYCLOAK_ISSUER"),
authorization: {
params: {
scope: "openid profile email roles"
}
},
profile(profile) {
// Simplified profile logging to reduce console noise
console.log('Keycloak profile received');
// Get roles from realm_access
const roles = profile.realm_access?.roles || [];
// Clean up roles by removing ROLE_ prefix and converting to lowercase
const cleanRoles = roles.map((role: string) =>
role.replace(/^ROLE_/, '').toLowerCase()
);
return {
id: profile.sub,
name: profile.name ?? profile.preferred_username,
email: profile.email,
first_name: profile.given_name ?? '',
last_name: profile.family_name ?? '',
username: profile.preferred_username ?? profile.email?.split('@')[0] ?? '',
role: cleanRoles,
}
},
}),
],
session: {
strategy: "jwt",
maxAge: 12 * 60 * 60, // Reduce to 12 hours to help with token size
},
cookies: {
sessionToken: {
name: `next-auth.session-token`,
options: {
httpOnly: true,
sameSite: 'none',
path: '/',
secure: true,
domain: process.env.NEXTAUTH_COOKIE_DOMAIN || undefined,
maxAge: 12 * 60 * 60, // Match session maxAge
},
},
},
jwt: {
// Maximum JWT size to prevent chunking
maxAge: 12 * 60 * 60, // Reduce to 12 hours
},
callbacks: {
async jwt({ token, account, profile }) {
// Initial sign in
if (account && profile) {
const keycloakProfile = profile as KeycloakProfile;
const roles = keycloakProfile.realm_access?.roles || [];
// Only include admin, owner, user roles (most critical)
const criticalRoles = roles
.filter(role =>
role.includes('admin') ||
role.includes('owner') ||
role.includes('user')
)
.map(role => role.replace(/^ROLE_/, '').toLowerCase());
// Store absolute minimal data in the token
token.accessToken = account.access_token;
token.refreshToken = account.refresh_token;
token.accessTokenExpires = account.expires_at ?? 0;
token.sub = keycloakProfile.sub;
token.role = criticalRoles.length > 0 ? criticalRoles : ['user']; // Only critical roles
token.username = keycloakProfile.preferred_username?.substring(0, 30) ?? '';
token.error = undefined;
// Don't store first/last name in the token to save space
// Applications can get these from the userinfo endpoint if needed
return token;
}
// Check if token has expired
const tokenExpiresAt = token.accessTokenExpires ? token.accessTokenExpires as number : 0;
const currentTime = Date.now();
const hasExpired = currentTime >= tokenExpiresAt;
// If the token is still valid, return it
if (!hasExpired) {
return token;
}
// If refresh token is missing, force sign in
if (!token.refreshToken) {
console.warn('No refresh token available, session cannot be refreshed');
return {
...token,
error: "RefreshAccessTokenError"
};
}
// Try to refresh the token
const refreshedToken = await refreshAccessToken(token as JWT);
// If there was an error refreshing, mark token for re-authentication
if (refreshedToken.error) {
console.warn('Token refresh failed, user will need to reauthenticate');
return {
...refreshedToken,
error: "RefreshAccessTokenError"
};
}
return refreshedToken;
},
async session({ session, token }) {
try {
// Handle the error from jwt callback
if (token.error === "RefreshAccessTokenError") {
console.warn("Session encountered a refresh token error, redirecting to login");
// Return minimal session with error flag that will trigger re-auth in client
return {
...session,
error: "RefreshTokenError",
user: {
id: token.sub ?? '',
role: ['user'], // Default role
username: '', // Empty username
first_name: '',
last_name: '',
name: null,
email: null,
image: null,
nextcloudInitialized: false,
}
};
}
const userRoles = Array.isArray(token.role) ? token.role : [];
// Create an extremely minimal user object
session.user = {
id: token.sub ?? '',
email: token.email ?? null,
name: token.name ?? null,
image: null,
username: token.username ?? '',
first_name: token.first_name ?? '',
last_name: token.last_name ?? '',
role: userRoles,
// Don't include nextcloudInitialized or other non-essential fields
};
// Only store access token, not the entire token
session.accessToken = token.accessToken;
return session;
} catch (error) {
console.error("Error in session callback:", error);
// Return minimal session with error flag
return {
...session,
error: "SessionError",
user: {
id: token.sub ?? '',
role: ['user'],
username: '',
first_name: '',
last_name: '',
name: null,
email: null,
image: null,
nextcloudInitialized: false,
}
};
}
}
},
pages: {
signIn: '/signin',
error: '/signin',
},
debug: false, // Disable debug to reduce cookie size from logging
};
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
interface JWT {
accessToken: string;
refreshToken: string;
accessTokenExpires: number;
}
interface Profile {
sub?: string;
email?: string;
name?: string;
roles?: string[];
}

View File

@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
export async function GET(req: NextRequest) {
try {
const session = await getServerSession(authOptions);
const userId = session?.user?.id;
const keycloakUrl = process.env.KEYCLOAK_ISSUER;
const clientId = process.env.KEYCLOAK_CLIENT_ID;
const redirectUri = req.nextUrl.searchParams.get('redirectUri') || process.env.NEXTAUTH_URL;
// Build Keycloak logout URL
let logoutUrl = `${keycloakUrl}/protocol/openid-connect/logout?client_id=${clientId}`;
if (redirectUri) {
logoutUrl += `&post_logout_redirect_uri=${encodeURIComponent(redirectUri)}`;
}
console.log(`Initiating full Keycloak logout for user ${userId || 'unknown'}`);
console.log(`Logout URL: ${logoutUrl}`);
return NextResponse.json({
success: true,
logoutUrl,
message: "Keycloak logout URL generated"
});
} catch (error) {
console.error("Error generating Keycloak logout URL:", error);
return NextResponse.json({
success: false,
error: "Failed to generate Keycloak logout URL"
}, { status: 500 });
}
}

View File

@ -0,0 +1,130 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth/next';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { getToken } from 'next-auth/jwt';
// Helper function to get user token using admin credentials
async function getUserTokenForRocketChat(email: string) {
try {
const baseUrl = process.env.NEXT_PUBLIC_IFRAME_PAROLE_URL?.split('/channel')[0];
if (!baseUrl) {
console.error('Failed to get Rocket.Chat base URL');
return null;
}
console.log(`Authenticating with Rocket.Chat at ${baseUrl} for user ${email}`);
// Admin headers for Rocket.Chat API
const adminHeaders = {
'X-Auth-Token': process.env.ROCKET_CHAT_TOKEN!,
'X-User-Id': process.env.ROCKET_CHAT_USER_ID!,
'Content-Type': 'application/json'
};
// Get the username from email
const username = email.split('@')[0];
console.log(`Derived username: ${username}`);
// Get all users to find the current user
const usersResponse = await fetch(`${baseUrl}/api/v1/users.list`, {
method: 'GET',
headers: adminHeaders
});
if (!usersResponse.ok) {
console.error(`Failed to get users list: ${usersResponse.status}`);
return null;
}
const usersData = await usersResponse.json();
console.log(`Retrieved ${usersData.users?.length || 0} users from Rocket.Chat`);
// Find the current user in the list - FIX: properly check email address
const currentUser = usersData.users.find((user: any) => {
// Check username match
if (user.username === username) {
return true;
}
// Check email match in emails array
if (user.emails && Array.isArray(user.emails)) {
return user.emails.some((emailObj: any) => emailObj.address === email);
}
return false;
});
if (!currentUser) {
console.error(`User not found in Rocket.Chat users list with username ${username} or email ${email}`);
// Try to log some users for debugging
const someUsers = usersData.users.slice(0, 3).map((u: any) => ({
username: u.username,
emails: u.emails,
name: u.name
}));
console.log('Sample users:', JSON.stringify(someUsers));
return null;
}
console.log(`Found user in Rocket.Chat: ${currentUser.username} (${currentUser._id})`);
// Create a token for the current user
const createTokenResponse = await fetch(`${baseUrl}/api/v1/users.createToken`, {
method: 'POST',
headers: adminHeaders,
body: JSON.stringify({
userId: currentUser._id
})
});
if (!createTokenResponse.ok) {
console.error(`Failed to create user token: ${createTokenResponse.status}`);
const errorText = await createTokenResponse.text();
console.error(`Error details: ${errorText}`);
return null;
}
const tokenData = await createTokenResponse.json();
console.log('Successfully created Rocket.Chat token');
return {
authToken: tokenData.data.authToken,
userId: currentUser._id
};
} catch (error) {
console.error('Error getting user token for Rocket.Chat:', error);
return null;
}
}
export async function GET(request: NextRequest) {
try {
// Get the current user session
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 });
}
const userEmail = session.user.email;
console.log(`Processing Rocket.Chat login for user ${userEmail}`);
// Get a token for Rocket.Chat
const rocketChatTokens = await getUserTokenForRocketChat(userEmail);
if (!rocketChatTokens) {
return NextResponse.json({ error: 'Failed to obtain Rocket.Chat tokens' }, { status: 500 });
}
// Return the tokens to the client
return NextResponse.json({
success: true,
rocketChatToken: rocketChatTokens.authToken,
rocketChatUserId: rocketChatTokens.userId
});
} catch (error) {
console.error('Error in Rocket.Chat login API:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@ -0,0 +1,85 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { getRedisClient } from '@/lib/redis';
import { closeUserImapConnections } from '@/lib/services/email-service';
/**
* API endpoint to clean up user sessions and invalidate cached data
* Called during logout to ensure proper cleanup of all connections
*/
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions);
const userId = session?.user?.id;
if (!userId) {
return NextResponse.json({ success: false, error: "No authenticated user found" }, { status: 401 });
}
await cleanupUserSessions(userId, false);
return NextResponse.json({ success: true, userId, message: "Session cleaned up" });
}
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const userId = body.userId;
const preserveSso = !!body.preserveSso;
if (!userId) {
return NextResponse.json({ success: false, error: "No user ID provided" }, { status: 400 });
}
await cleanupUserSessions(userId, preserveSso);
return NextResponse.json({
success: true,
userId,
preserveSso,
message: `Session cleaned up${preserveSso ? ' (SSO preserved)' : ''}`
});
} catch (error) {
console.error("Error in session-cleanup POST:", error);
return NextResponse.json({ success: false, error: "Failed to parse request" }, { status: 400 });
}
}
async function cleanupUserSessions(userId: string, preserveSso: boolean) {
try {
const redis = await getRedisClient();
if (!redis) {
console.error("Redis client not available for session cleanup");
return;
}
console.log(`Cleaning up sessions for user ${userId}${preserveSso ? ' (preserving SSO)' : ''}`);
// Find all keys for this user
const userKeys = await redis.keys(`*:${userId}*`);
if (userKeys.length > 0) {
console.log(`Found ${userKeys.length} keys for user ${userId}:`, userKeys);
// If preserving SSO, only delete application-specific keys
const keysToDelete = preserveSso
? userKeys.filter(key => !key.includes('keycloak') && !key.includes('sso'))
: userKeys;
if (keysToDelete.length > 0) {
await redis.del(keysToDelete);
console.log(`Deleted ${keysToDelete.length} keys for user ${userId}`);
} else {
console.log(`No application keys to delete while preserving SSO`);
}
} else {
console.log(`No keys found for user ${userId}`);
}
// Close any active IMAP connections
await closeUserImapConnections(userId);
} catch (error) {
console.error(`Error cleaning up sessions for user ${userId}:`, error);
}
}

View File

@ -0,0 +1,54 @@
import { NextResponse } from 'next/server';
import { headers } from 'next/headers';
// This API serves as documentation for code refactoring steps
export async function GET() {
const headersList = headers();
const updates = [
{
id: 1,
description: "Created consistent task status utilities",
file: "lib/utils/status-utils.ts",
date: "2023-07-15",
details: "Centralized task status color and label handling"
},
{
id: 2,
description: "Created date validation utilities",
file: "lib/utils/date-utils.ts",
date: "2023-07-15",
details: "Added isValidDateString and other date handling utilities"
},
{
id: 3,
description: "Created useTasks custom hook",
file: "hooks/use-tasks.ts",
date: "2023-07-16",
details: "Centralized task fetching logic into a reusable React hook"
},
{
id: 4,
description: "Refactored Duties component in flow.tsx",
file: "components/flow.tsx",
date: "2023-07-16",
details: "Updated to use the new useTasks hook instead of built-in task fetching"
},
{
id: 5,
description: "Created useCalendarEvents custom hook",
file: "hooks/use-calendar-events.ts",
date: "2023-07-16",
details: "Centralized calendar event fetching logic into a reusable React hook"
},
{
id: 6,
description: "Refactored Calendar component",
file: "components/calendar.tsx",
date: "2023-07-16",
details: "Updated to use the new useCalendarEvents hook for improved maintainability"
}
];
return NextResponse.json({ updates });
}

View File

@ -0,0 +1,70 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { toggleEmailFlag } from '@/lib/services/email-service';
import { invalidateEmailContentCache, invalidateFolderCache } from '@/lib/redis';
export async function POST(
request: Request,
context: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session || !session.user?.id) {
return NextResponse.json(
{ error: "Not authenticated" },
{ status: 401 }
);
}
// Await params as per Next.js requirements
const params = await context.params;
const id = params?.id;
if (!id) {
return NextResponse.json(
{ error: "Missing email ID" },
{ status: 400 }
);
}
const { flagged, folder, accountId } = await request.json();
if (typeof flagged !== 'boolean') {
return NextResponse.json(
{ error: "Invalid 'flagged' parameter. Must be a boolean." },
{ status: 400 }
);
}
const normalizedFolder = folder || "INBOX";
const effectiveAccountId = accountId || 'default';
// Use the email service to toggle the flag
// Note: You'll need to implement this function in email-service.ts
const success = await toggleEmailFlag(
session.user.id,
id,
flagged,
normalizedFolder,
effectiveAccountId
);
if (!success) {
return NextResponse.json(
{ error: `Failed to ${flagged ? 'star' : 'unstar'} email` },
{ status: 500 }
);
}
// Invalidate cache for this email
await invalidateEmailContentCache(session.user.id, effectiveAccountId, id);
return NextResponse.json({ success: true });
} catch (error: any) {
console.error("Error in flag API:", error);
return NextResponse.json(
{ error: "Internal server error", message: error.message },
{ status: 500 }
);
}
}

View File

@ -2,6 +2,7 @@ import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { markEmailReadStatus } from '@/lib/services/email-service';
import { invalidateEmailContentCache, invalidateFolderCache } from '@/lib/redis';
// Global cache reference (will be moved to a proper cache solution in the future)
declare global {
@ -30,62 +31,66 @@ const invalidateCache = (userId: string, folder?: string) => {
// Mark email as read
export async function POST(
request: Request,
{ params }: { params: { id: string } }
context: { params: { id: string } }
) {
try {
// Get session
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// No need to await Promise.resolve(), params is already an object
const { id: emailId } = params;
if (!emailId) {
return NextResponse.json({ error: 'Email ID is required' }, { status: 400 });
}
try {
// Use the email service to mark the email as read
// First try with INBOX folder
let success = await markEmailReadStatus(session.user.id, emailId, true, 'INBOX');
// If not found in INBOX, try to find it in other common folders
if (!success) {
const commonFolders = ['Sent', 'Drafts', 'Trash', 'Spam', 'Junk'];
for (const folder of commonFolders) {
success = await markEmailReadStatus(session.user.id, emailId, true, folder);
if (success) {
// If found in a different folder, invalidate that folder's cache
invalidateCache(session.user.id, folder);
break;
}
}
} else {
// Email found in INBOX, invalidate INBOX cache
invalidateCache(session.user.id, 'INBOX');
}
if (!success) {
return NextResponse.json(
{ error: 'Email not found in any folder' },
{ status: 404 }
);
}
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error marking email as read:', error);
if (!session || !session.user?.id) {
return NextResponse.json(
{ error: 'Failed to mark email as read' },
{ error: "Not authenticated" },
{ status: 401 }
);
}
// Await params as per Next.js requirements
const params = await context.params;
const id = params?.id;
if (!id) {
return NextResponse.json(
{ error: "Missing email ID" },
{ status: 400 }
);
}
const { isRead, folder, accountId } = await request.json();
if (typeof isRead !== 'boolean') {
return NextResponse.json(
{ error: "Invalid 'isRead' parameter. Must be a boolean." },
{ status: 400 }
);
}
const normalizedFolder = folder || "INBOX";
const effectiveAccountId = accountId || 'default';
// Use the email service to mark the email
const success = await markEmailReadStatus(
session.user.id,
id,
isRead,
normalizedFolder,
effectiveAccountId
);
if (!success) {
return NextResponse.json(
{ error: `Failed to ${isRead ? 'mark email as read' : 'mark email as unread'}` },
{ status: 500 }
);
}
} catch (error) {
console.error('Error marking email as read:', error);
// Invalidate cache for this email
await invalidateEmailContentCache(session.user.id, effectiveAccountId, id);
// Also invalidate folder cache to update unread counts
await invalidateFolderCache(session.user.id, effectiveAccountId, normalizedFolder);
return NextResponse.json({ success: true });
} catch (error: any) {
console.error("Error in mark-read API:", error);
return NextResponse.json(
{ error: 'Failed to mark email as read' },
{ error: "Internal server error", message: error.message },
{ status: 500 }
);
}

View File

@ -10,10 +10,11 @@ import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { getEmailContent, markEmailReadStatus } from '@/lib/services/email-service';
import { getCachedEmailContent, invalidateEmailContentCache } from '@/lib/redis';
export async function GET(
request: Request,
{ params }: { params: { id: string } }
context: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);
@ -24,7 +25,9 @@ export async function GET(
);
}
const { id } = params;
// Await params as per Next.js requirements
const params = await context.params;
const id = params?.id;
if (!id) {
return NextResponse.json(
{ error: "Missing email ID" },
@ -34,12 +37,22 @@ export async function GET(
const { searchParams } = new URL(request.url);
const folder = searchParams.get("folder") || "INBOX";
const accountId = searchParams.get("accountId");
try {
// Use the email service to fetch the email content
const email = await getEmailContent(session.user.id, id, folder);
// Try to get email from Redis cache first
const cachedEmail = await getCachedEmailContent(session.user.id, accountId || 'default', id);
if (cachedEmail) {
console.log(`Using cached email content for ${session.user.id}:${id}`);
return NextResponse.json(cachedEmail);
}
// Return the complete email object instead of just partial data
console.log(`Cache miss for email content ${session.user.id}:${id}, fetching from IMAP`);
// Use the email service to fetch the email content
const email = await getEmailContent(session.user.id, id, folder, accountId || undefined);
// Return the complete email object
return NextResponse.json(email);
} catch (error: any) {
console.error("Error fetching email content:", error);
@ -60,7 +73,7 @@ export async function GET(
// Add a route to mark email as read
export async function POST(
request: Request,
{ params }: { params: { id: string } }
context: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);
@ -71,7 +84,9 @@ export async function POST(
);
}
const { id } = params;
// Await params as per Next.js requirements
const params = await context.params;
const id = params?.id;
if (!id) {
return NextResponse.json(
{ error: "Missing email ID" },
@ -90,6 +105,7 @@ export async function POST(
const { searchParams } = new URL(request.url);
const folder = searchParams.get("folder") || "INBOX";
const accountId = searchParams.get("accountId");
// Use the email service to mark the email
const success = await markEmailReadStatus(
@ -106,6 +122,9 @@ export async function POST(
);
}
// Invalidate cache for this email
await invalidateEmailContentCache(session.user.id, accountId || 'default', id);
return NextResponse.json({ success: true });
} catch (error: any) {
console.error("Error in POST:", error);

View File

@ -0,0 +1,64 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { prisma } from '@/lib/prisma';
export async function GET(request: Request) {
try {
// Authenticate user
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
// Get accountId from query params
const { searchParams } = new URL(request.url);
const accountId = searchParams.get('accountId');
if (!accountId) {
return NextResponse.json(
{ error: 'Account ID is required' },
{ status: 400 }
);
}
// Get account details from database, including connection details
const account = await prisma.mailCredentials.findFirst({
where: {
id: accountId,
userId: session.user.id
},
select: {
id: true,
email: true,
host: true,
port: true,
secure: true,
display_name: true,
color: true,
// Don't include the password in the response
}
});
if (!account) {
return NextResponse.json(
{ error: 'Account not found' },
{ status: 404 }
);
}
return NextResponse.json(account);
} catch (error) {
console.error('Error fetching account details:', error);
return NextResponse.json(
{
error: 'Failed to fetch account details',
details: error instanceof Error ? error.message : 'Unknown error'
},
{ status: 500 }
);
}
}

View File

@ -0,0 +1,170 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { getMailboxes } from '@/lib/services/email-service';
import { ImapFlow } from 'imapflow';
import { prisma } from '@/lib/prisma';
import { getCachedEmailCredentials } from '@/lib/redis';
export async function GET(request: Request) {
// Verify auth
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const { searchParams } = new URL(request.url);
const accountId = searchParams.get('accountId');
const userId = session.user.id;
try {
// If specific accountId is provided, get folders for that account
if (accountId) {
// Get account from database
const account = await prisma.mailCredentials.findFirst({
where: {
id: accountId,
userId
},
select: {
email: true,
password: true,
host: true,
port: true
}
});
if (!account) {
return NextResponse.json(
{ error: 'Account not found' },
{ status: 404 }
);
}
// Connect to IMAP server for this account
const client = new ImapFlow({
host: account.host,
port: account.port,
secure: true,
auth: {
user: account.email,
pass: account.password,
},
logger: false,
tls: {
rejectUnauthorized: false
}
});
try {
await client.connect();
// Get folders for this account
const folders = await getMailboxes(client);
// Close connection
await client.logout();
return NextResponse.json({
accountId,
email: account.email,
folders
});
} catch (error) {
return NextResponse.json(
{
error: 'Failed to connect to IMAP server',
details: error instanceof Error ? error.message : 'Unknown error'
},
{ status: 500 }
);
}
} else {
// Get all accounts for this user
const accounts = await prisma.mailCredentials.findMany({
where: { userId },
select: {
id: true,
email: true,
password: true,
host: true,
port: true
}
});
if (accounts.length === 0) {
return NextResponse.json({
accounts: []
});
}
// Fetch folders for each account individually
const accountsWithFolders = await Promise.all(accounts.map(async (account) => {
try {
// Connect to IMAP server for this specific account
const client = new ImapFlow({
host: account.host,
port: account.port,
secure: true,
auth: {
user: account.email,
pass: account.password,
},
logger: false,
tls: {
rejectUnauthorized: false
}
});
await client.connect();
// Get folders for this account
const folders = await getMailboxes(client);
// Close connection
await client.logout();
// Add display_name and color from database
const metadata = await prisma.$queryRaw`
SELECT display_name, color
FROM "MailCredentials"
WHERE id = ${account.id}
`;
const displayMetadata = Array.isArray(metadata) && metadata.length > 0 ? metadata[0] : {};
return {
id: account.id,
email: account.email,
display_name: displayMetadata.display_name || account.email,
color: displayMetadata.color || "#0082c9",
folders
};
} catch (error) {
console.error(`Error fetching folders for account ${account.email}:`, error);
// Return fallback folders on error
return {
id: account.id,
email: account.email,
folders: ['INBOX', 'Sent', 'Drafts', 'Trash'] // Fallback folders on error
};
}
}));
return NextResponse.json({
accounts: accountsWithFolders
});
}
} catch (error) {
return NextResponse.json(
{
error: 'Failed to get account folders',
details: error instanceof Error ? error.message : 'Unknown error'
},
{ status: 500 }
);
}
}

View File

@ -0,0 +1,51 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { prisma } from '@/lib/prisma';
export async function GET(request: Request) {
try {
// Authenticate user
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
// Get all email accounts for this user
const accounts = await prisma.mailCredentials.findMany({
where: { userId: session.user.id },
select: {
id: true,
email: true,
host: true,
port: true,
secure: true,
display_name: true,
color: true,
smtp_host: true,
smtp_port: true,
smtp_secure: true,
createdAt: true,
updatedAt: true
}
});
// Never return passwords
return NextResponse.json({
success: true,
accounts
});
} catch (error) {
console.error('Error fetching email accounts:', error);
return NextResponse.json(
{
error: 'Failed to fetch email accounts',
details: error instanceof Error ? error.message : 'Unknown error'
},
{ status: 500 }
);
}
}

View File

@ -0,0 +1,294 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { saveUserEmailCredentials, testEmailConnection } from '@/lib/services/email-service';
import { invalidateFolderCache } from '@/lib/redis';
import { prisma } from '@/lib/prisma';
// Define EmailCredentials interface inline since we're having import issues
interface EmailCredentials {
email: string;
password?: string;
host: string;
port: number;
secure?: boolean;
smtp_host?: string;
smtp_port?: number;
smtp_secure?: boolean;
display_name?: string;
color?: string;
}
/**
* Check if a user exists in the database
*/
async function userExists(userId: string): Promise<boolean> {
try {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { id: true }
});
return !!user;
} catch (error) {
console.error(`Error checking if user exists:`, error);
return false;
}
}
export async function POST(request: Request) {
try {
// Authenticate user
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
// Verify that the user exists in the database
const userExistsInDB = await userExists(session.user.id);
if (!userExistsInDB) {
console.error(`User with ID ${session.user.id} not found in database`);
return NextResponse.json(
{
error: 'User not found in database',
details: `The user ID from your session (${session.user.id}) doesn't exist in the database. This may be due to a session/database mismatch.`
},
{ status: 400 }
);
}
// Parse request body
const body = await request.json().catch(e => {
console.error('Error parsing request body:', e);
return {};
});
// Log the request (but hide password)
console.log('Adding account:', {
...body,
password: body.password ? '***' : undefined
});
const {
email,
password,
host,
port,
secure,
smtp_host,
smtp_port,
smtp_secure,
display_name,
color
} = body;
// Validate required fields
const missingFields = [];
if (!email) missingFields.push('email');
if (!password) missingFields.push('password');
if (!host) missingFields.push('host');
if (port === undefined) missingFields.push('port');
if (missingFields.length > 0) {
console.error(`Missing required fields: ${missingFields.join(', ')}`);
return NextResponse.json(
{ error: `Required fields missing: ${missingFields.join(', ')}` },
{ status: 400 }
);
}
// Fix common hostname errors - strip http/https prefixes
let cleanHost = host;
if (cleanHost.startsWith('http://')) {
cleanHost = cleanHost.substring(7);
} else if (cleanHost.startsWith('https://')) {
cleanHost = cleanHost.substring(8);
}
// Create credentials object
const credentials: EmailCredentials = {
email,
password,
host: cleanHost,
port: typeof port === 'string' ? parseInt(port) : port,
secure: secure ?? true,
// Optional SMTP settings
...(smtp_host && { smtp_host }),
...(smtp_port && { smtp_port: typeof smtp_port === 'string' ? parseInt(smtp_port) : smtp_port }),
...(smtp_secure !== undefined && { smtp_secure }),
// Optional display settings
...(display_name && { display_name }),
...(color && { color })
};
// Test connection before saving
console.log(`Testing connection before saving for user ${session.user.id}`);
const testResult = await testEmailConnection(credentials);
if (!testResult.imap) {
return NextResponse.json(
{ error: `Connection test failed: ${testResult.error || 'Could not connect to IMAP server'}` },
{ status: 400 }
);
}
// Save credentials to database and cache
console.log(`Saving credentials for user: ${session.user.id}`);
await saveUserEmailCredentials(session.user.id, email, credentials);
console.log(`Email account successfully added for user ${session.user.id}`);
// Fetch the created account from the database
const createdAccount = await prisma.mailCredentials.findFirst({
where: { userId: session.user.id, email },
select: {
id: true,
email: true,
display_name: true,
color: true,
}
});
// Invalidate all folder caches for this user/account
await invalidateFolderCache(session.user.id, email, '*');
return NextResponse.json({
success: true,
account: createdAccount,
message: 'Email account added successfully'
});
} catch (error) {
console.error('Error adding email account:', error);
return NextResponse.json(
{
error: 'Failed to add email account',
details: error instanceof Error ? error.message : 'Unknown error'
},
{ status: 500 }
);
}
}
export async function DELETE(request: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const accountId = searchParams.get('accountId');
if (!accountId) {
return NextResponse.json({ error: 'Missing accountId' }, { status: 400 });
}
// Find the account
const account = await prisma.mailCredentials.findFirst({
where: { id: accountId, userId: session.user.id },
});
if (!account) {
return NextResponse.json({ error: 'Account not found' }, { status: 404 });
}
// Delete from database
await prisma.mailCredentials.delete({ where: { id: accountId } });
// Invalidate cache
await invalidateFolderCache(session.user.id, account.email, '*');
return NextResponse.json({ success: true, message: 'Account deleted' });
} catch (error) {
console.error('Error deleting account:', error);
return NextResponse.json({ error: 'Failed to delete account', details: error instanceof Error ? error.message : 'Unknown error' }, { status: 500 });
}
}
export async function PATCH(request: Request) {
try {
// Authenticate user
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
// Parse request body
const body = await request.json();
const { accountId, newPassword, display_name, color } = body;
if (!accountId) {
return NextResponse.json(
{ error: 'Account ID is required' },
{ status: 400 }
);
}
// Check if at least one of the fields is provided
if (!newPassword && !display_name && !color) {
return NextResponse.json(
{ error: 'At least one field to update is required' },
{ status: 400 }
);
}
// Verify the account belongs to the user
const account = await prisma.mailCredentials.findFirst({
where: {
id: accountId,
userId: session.user.id
}
});
if (!account) {
return NextResponse.json(
{ error: 'Account not found' },
{ status: 404 }
);
}
// Build update data object
const updateData: any = {};
// Add password if provided
if (newPassword) {
updateData.password = newPassword;
}
// Add display_name if provided
if (display_name !== undefined) {
updateData.display_name = display_name;
}
// Add color if provided
if (color) {
updateData.color = color;
}
// Update the account
await prisma.mailCredentials.update({
where: { id: accountId },
data: updateData
});
return NextResponse.json({
success: true,
message: 'Account updated successfully'
});
} catch (error) {
console.error('Error updating account:', error);
return NextResponse.json(
{
error: 'Failed to update account',
details: error instanceof Error ? error.message : 'Unknown error'
},
{ status: 500 }
);
}
}
const handleAddAccount = async (accountData: AccountData) => {
// ... account creation logic ...
// setAccounts(prev => [...prev, newAccount]);
// setVisibleFolders(prev => ({
// ...prev,
// [newAccount.id]: newAccount.folders
// }));
};

View File

@ -0,0 +1,182 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { getCachedEmailCredentials, getCachedImapSession } from '@/lib/redis';
import { prisma } from '@/lib/prisma';
import { getMailboxes } from '@/lib/services/email-service';
import { ImapFlow } from 'imapflow';
export async function GET() {
// Verify auth
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const userId = session.user.id;
const debugData: any = {
userId,
timestamp: new Date().toISOString(),
redis: {
emailCredentials: null,
session: null
},
database: {
accounts: [],
schema: null
},
imap: {
connectionAttempt: false,
connected: false,
folders: []
}
};
// Check Redis cache for credentials
try {
const credentials = await getCachedEmailCredentials(userId);
if (credentials) {
debugData.redis.emailCredentials = {
found: true,
email: credentials.email,
host: credentials.host,
port: credentials.port,
hasPassword: !!credentials.password,
hasSmtp: !!credentials.smtp_host
};
} else {
debugData.redis.emailCredentials = { found: false };
}
} catch (e) {
debugData.redis.emailCredentials = {
error: e instanceof Error ? e.message : 'Unknown error'
};
}
// Check Redis for session data (which contains folders)
try {
const sessionData = await getCachedImapSession(userId);
if (sessionData) {
debugData.redis.session = {
found: true,
lastActive: new Date(sessionData.lastActive).toISOString(),
hasFolders: !!sessionData.mailboxes,
folderCount: sessionData.mailboxes?.length || 0,
folders: sessionData.mailboxes || []
};
} else {
debugData.redis.session = { found: false };
}
} catch (e) {
debugData.redis.session = {
error: e instanceof Error ? e.message : 'Unknown error'
};
}
// Try to get database schema information to help diagnose issues
try {
const schemaInfo = await prisma.$queryRaw`
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'MailCredentials'
AND table_schema = 'public'
ORDER BY ordinal_position
`;
debugData.database.schema = schemaInfo;
} catch (e) {
debugData.database.schemaError = e instanceof Error ? e.message : 'Unknown error';
}
// Check database for accounts
try {
const accounts = await prisma.mailCredentials.findMany({
where: { userId },
select: {
id: true,
email: true,
host: true,
port: true
}
});
// Also try to get additional fields from raw query
const accountsWithMetadata = await Promise.all(accounts.map(async (account) => {
try {
const rawAccount = await prisma.$queryRaw`
SELECT display_name, color, smtp_host, smtp_port, smtp_secure, secure
FROM "MailCredentials"
WHERE id = ${account.id}
`;
const metadata = Array.isArray(rawAccount) && rawAccount.length > 0
? rawAccount[0]
: {};
return {
...account,
display_name: metadata.display_name,
color: metadata.color,
smtp_host: metadata.smtp_host,
smtp_port: metadata.smtp_port,
smtp_secure: metadata.smtp_secure,
secure: metadata.secure
};
} catch (e) {
return {
...account,
_queryError: e instanceof Error ? e.message : 'Unknown error'
};
}
}));
debugData.database.accounts = accountsWithMetadata;
debugData.database.accountCount = accounts.length;
} catch (e) {
debugData.database.error = e instanceof Error ? e.message : 'Unknown error';
}
// Try to get IMAP folders for the main account
if (debugData.redis.emailCredentials?.found || debugData.database.accountCount > 0) {
try {
debugData.imap.connectionAttempt = true;
// Use cached credentials
const credentials = await getCachedEmailCredentials(userId);
if (credentials && credentials.email && credentials.password) {
const client = new ImapFlow({
host: credentials.host,
port: credentials.port,
secure: true,
auth: {
user: credentials.email,
pass: credentials.password,
},
logger: false,
tls: {
rejectUnauthorized: false
}
});
await client.connect();
debugData.imap.connected = true;
// Get folders
const folders = await getMailboxes(client);
debugData.imap.folders = folders;
// Close connection
await client.logout();
} else {
debugData.imap.error = "No valid credentials found";
}
} catch (e) {
debugData.imap.error = e instanceof Error ? e.message : 'Unknown error';
}
}
return NextResponse.json(debugData);
}

View File

@ -0,0 +1,133 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { getImapConnection } from '@/lib/services/email-service';
import { invalidateFolderCache } from '@/lib/redis';
export async function POST(request: Request) {
try {
// Authenticate user
const session = await getServerSession(authOptions);
if (!session || !session.user?.id) {
return NextResponse.json(
{ error: "Not authenticated" },
{ status: 401 }
);
}
// Extract request body
const body = await request.json();
const { emailIds, folder, accountId } = body;
// Validate required parameters
if (!emailIds || !Array.isArray(emailIds) || emailIds.length === 0) {
console.error('[DELETE API] Missing or invalid emailIds parameter:', emailIds);
return NextResponse.json(
{ error: "Missing or invalid emailIds parameter" },
{ status: 400 }
);
}
if (!folder) {
console.error('[DELETE API] Missing folder parameter');
return NextResponse.json(
{ error: "Missing folder parameter" },
{ status: 400 }
);
}
// Extract account ID from folder name if present and none was explicitly provided
const folderAccountId = folder.includes(':') ? folder.split(':')[0] : undefined;
// Use the most specific account ID available
const effectiveAccountId = folderAccountId || accountId || 'default';
// Normalize folder name by removing account prefix if present
const normalizedFolder = folder.includes(':') ? folder.split(':')[1] : folder;
console.log(`[DELETE API] Deleting ${emailIds.length} emails from folder ${normalizedFolder}, account ${effectiveAccountId}`);
// Get IMAP connection
const client = await getImapConnection(session.user.id, effectiveAccountId);
try {
// Open the mailbox
await client.mailboxOpen(normalizedFolder);
// Check if we're already in the trash folder
const inTrash = normalizedFolder.toLowerCase() === 'trash' ||
normalizedFolder.toLowerCase() === 'bin' ||
normalizedFolder.toLowerCase() === 'deleted';
if (inTrash) {
// If we're in trash, mark as deleted
console.log(`[DELETE API] In trash folder, marking emails as deleted: ${emailIds.join(', ')}`);
// Mark messages as deleted
for (const emailId of emailIds) {
await client.messageFlagsAdd(emailId, ['\\Deleted']);
}
} else {
// If not in trash, move to trash
console.log(`[DELETE API] Moving emails to trash: ${emailIds.join(', ')}`);
// Try to find the trash folder
const mailboxes = await client.list();
let trashFolder = 'Trash';
// Look for common trash folder names
const trashFolderNames = ['Trash', 'TRASH', 'Bin', 'Deleted', 'Deleted Items'];
for (const folder of mailboxes) {
if (trashFolderNames.includes(folder.name) ||
trashFolderNames.some(name => folder.name.toLowerCase().includes(name.toLowerCase()))) {
trashFolder = folder.name;
break;
}
}
// Move messages to trash
for (const emailId of emailIds) {
try {
// Convert the emailId to a number if it's a string
const uid = typeof emailId === 'string' ? parseInt(emailId, 10) : emailId;
// Debug logging for troubleshooting
console.log(`[DELETE API] Moving email with UID ${uid} to trash folder "${trashFolder}"`);
// Use the correct syntax for messageMove method
await client.messageMove(uid.toString(), trashFolder, { uid: true });
console.log(`[DELETE API] Successfully moved email ${uid} to trash`);
} catch (moveError) {
console.error(`[DELETE API] Error moving email ${emailId} to trash:`, moveError);
// Continue with other emails even if one fails
}
}
}
// Invalidate cache for source folder
await invalidateFolderCache(session.user.id, effectiveAccountId, normalizedFolder);
// Also invalidate trash folder cache
await invalidateFolderCache(session.user.id, effectiveAccountId, 'Trash');
return NextResponse.json({
success: true,
message: `${emailIds.length} email(s) deleted successfully`
});
} finally {
try {
// Always close the mailbox
await client.mailboxClose();
} catch (error) {
console.error('Error closing mailbox:', error);
}
}
} catch (error) {
console.error('[DELETE API] Error processing delete request:', error);
return NextResponse.json(
{ error: "Failed to delete emails", details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,88 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { getEmails } from '@/lib/services/email-service';
import {
getCachedEmailList,
cacheEmailList,
invalidateFolderCache
} from '@/lib/redis';
export async function GET(request: Request) {
try {
// Authenticate user
const session = await getServerSession(authOptions);
if (!session || !session.user?.id) {
return NextResponse.json(
{ error: "Not authenticated" },
{ status: 401 }
);
}
// Extract query parameters
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get("page") || "1");
const perPage = parseInt(searchParams.get("perPage") || "20");
const folder = searchParams.get("folder") || "INBOX";
const searchQuery = searchParams.get("search") || "";
const accountId = searchParams.get("accountId") || "";
const checkOnly = searchParams.get("checkOnly") === "true";
// Log exact parameters received by the API
console.log(`[API/emails] Received request with: folder=${folder}, accountId=${accountId}, page=${page}, checkOnly=${checkOnly}`);
// Parameter normalization
// If folder contains an account prefix, extract it but DO NOT use it
// Always prioritize the explicit accountId parameter
let normalizedFolder = folder;
let effectiveAccountId = accountId || 'default';
if (folder.includes(':')) {
const parts = folder.split(':');
normalizedFolder = parts[1];
console.log(`[API/emails] Folder has prefix, normalized to ${normalizedFolder}`);
}
console.log(`[API/emails] Using normalized parameters: folder=${normalizedFolder}, accountId=${effectiveAccountId}`);
// Try to get from Redis cache first, but only if it's not a search query and not checkOnly
if (!searchQuery && !checkOnly) {
console.log(`[API/emails] Checking Redis cache for ${session.user.id}:${effectiveAccountId}:${normalizedFolder}:${page}:${perPage}`);
const cachedEmails = await getCachedEmailList(
session.user.id,
effectiveAccountId,
normalizedFolder,
page,
perPage
);
if (cachedEmails) {
console.log(`[API/emails] Using Redis cached emails for ${session.user.id}:${effectiveAccountId}:${normalizedFolder}:${page}:${perPage}`);
return NextResponse.json(cachedEmails);
}
}
console.log(`[API/emails] Redis cache miss for ${session.user.id}:${effectiveAccountId}:${normalizedFolder}:${page}:${perPage}, fetching emails from IMAP`);
// Use the email service to fetch emails
const emailsResult = await getEmails(
session.user.id,
normalizedFolder,
page,
perPage,
effectiveAccountId,
checkOnly
);
console.log(`[API/emails] Successfully fetched ${emailsResult.emails.length} emails from IMAP for account ${effectiveAccountId}`);
// Return result
return NextResponse.json(emailsResult);
} catch (error: any) {
console.error("[API/emails] Error fetching emails:", error);
return NextResponse.json(
{ error: "Failed to fetch emails", message: error.message },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,116 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { prisma } from '@/lib/prisma';
import { getMailboxes } from '@/lib/services/email-service';
import { ImapFlow } from 'imapflow';
import { cacheImapSession, getCachedImapSession } from '@/lib/redis';
export async function POST() {
// Verify auth
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const userId = session.user.id;
const results = {
success: false,
userId,
accountsProcessed: 0,
foldersFound: 0,
accounts: [] as any[]
};
try {
// Get all accounts for this user
const accounts = await prisma.mailCredentials.findMany({
where: { userId },
select: {
id: true,
email: true,
password: true,
host: true,
port: true
}
});
if (accounts.length === 0) {
return NextResponse.json({
success: false,
error: 'No email accounts found'
});
}
// Process each account
for (const account of accounts) {
try {
// Connect to IMAP server for this account
const client = new ImapFlow({
host: account.host,
port: account.port,
secure: true,
auth: {
user: account.email,
pass: account.password,
},
logger: false,
tls: {
rejectUnauthorized: false
}
});
await client.connect();
// Get folders for this account
const folders = await getMailboxes(client);
// Store the results
results.accounts.push({
id: account.id,
email: account.email,
folderCount: folders.length,
folders
});
results.foldersFound += folders.length;
results.accountsProcessed++;
// Get existing session data
const existingSession = await getCachedImapSession(userId);
// Update the Redis cache with the folders
await cacheImapSession(userId, {
...(existingSession || { lastActive: Date.now() }),
mailboxes: folders,
lastVisit: Date.now()
});
// Close connection
await client.logout();
} catch (error) {
results.accounts.push({
id: account.id,
email: account.email,
error: error instanceof Error ? error.message : 'Unknown error'
});
}
}
results.success = results.accountsProcessed > 0;
return NextResponse.json(results);
} catch (error) {
return NextResponse.json(
{
success: false,
error: 'Failed to fix folders',
details: error instanceof Error ? error.message : 'Unknown error'
},
{ status: 500 }
);
}
}

View File

@ -1,102 +0,0 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import {
saveUserEmailCredentials,
getUserEmailCredentials,
testEmailConnection
} from '@/lib/services/email-service';
export async function POST(request: Request) {
try {
// Authenticate user
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
// Get credentials from request
const { email, password, host, port } = await request.json();
// Validate required fields
if (!email || !password || !host || !port) {
return NextResponse.json(
{ error: 'Missing required fields' },
{ status: 400 }
);
}
// Test connection before saving
const connectionSuccess = await testEmailConnection({
email,
password,
host,
port: parseInt(port)
});
if (!connectionSuccess) {
return NextResponse.json(
{ error: 'Failed to connect to email server. Please check your credentials.' },
{ status: 401 }
);
}
// Save credentials in the database
await saveUserEmailCredentials(session.user.id, {
email,
password,
host,
port: parseInt(port)
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error in login handler:', error);
return NextResponse.json(
{
error: 'An unexpected error occurred',
details: error instanceof Error ? error.message : 'Unknown error'
},
{ status: 500 }
);
}
}
export async function GET() {
try {
// Authenticate user
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
// Get user credentials from database
const credentials = await getUserEmailCredentials(session.user.id);
if (!credentials) {
return NextResponse.json(
{ error: 'No stored credentials found' },
{ status: 404 }
);
}
// Return credentials without the password
return NextResponse.json({
email: credentials.email,
host: credentials.host,
port: credentials.port
});
} catch (error) {
console.error('Error fetching credentials:', error);
return NextResponse.json(
{ error: 'Failed to retrieve credentials' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,149 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { exchangeCodeForTokens } from '@/lib/services/microsoft-oauth';
import { prisma } from '@/lib/prisma';
import { testEmailConnection, saveUserEmailCredentials } from '@/lib/services/email-service';
import { invalidateFolderCache } from '@/lib/redis';
import { cacheEmailCredentials } from '@/lib/redis';
export async function POST(request: Request) {
try {
// Authenticate user
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
// Parse request body
const body = await request.json();
const { code, state } = body;
if (!code || !state) {
return NextResponse.json(
{ error: 'Missing required parameters' },
{ status: 400 }
);
}
// Validate state parameter to prevent CSRF
try {
const decodedState = JSON.parse(Buffer.from(state, 'base64').toString());
// Check if state contains valid userId and is not expired (10 minutes)
if (decodedState.userId !== session.user.id ||
Date.now() - decodedState.timestamp > 10 * 60 * 1000) {
return NextResponse.json(
{ error: 'Invalid or expired state parameter' },
{ status: 400 }
);
}
} catch (e) {
return NextResponse.json(
{ error: 'Invalid state parameter' },
{ status: 400 }
);
}
// Exchange code for tokens
const tokens = await exchangeCodeForTokens(code);
// Extract user email from session instead of generating a fake one
// Use the logged-in user's email or a properly formatted address
let userEmail = '';
if (session.user?.email) {
// Use the user's actual email if available
userEmail = session.user.email;
} else {
// Fallback to a default format - don't add @microsoft.com
userEmail = `unknown-user-${session.user.id}@outlook.com`;
}
console.log(`Using email: ${userEmail} for Microsoft account`);
// Create credentials object for Microsoft account
const credentials = {
email: userEmail,
// Password is empty for OAuth accounts - use a placeholder to meet database schema requirements
password: 'microsoft-oauth2-account',
// Use Microsoft's IMAP server for Outlook/Office365
host: 'outlook.office365.com',
port: 993,
secure: true,
// OAuth specific fields
useOAuth: true, // Make sure this is explicitly set
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
tokenExpiry: Date.now() + (tokens.expires_in * 1000),
// Optional fields
display_name: `Microsoft (${userEmail})`,
color: '#0078D4', // Microsoft blue
// SMTP settings for Microsoft
smtp_host: 'smtp.office365.com',
smtp_port: 587,
smtp_secure: false
};
// Log Microsoft authentication details
console.log(`Microsoft OAuth credentials prepared for ${userEmail}:`, {
useOAuth: credentials.useOAuth,
host: credentials.host,
hasAccessToken: !!credentials.accessToken,
hasRefreshToken: !!credentials.refreshToken,
tokenExpiry: new Date(credentials.tokenExpiry).toISOString()
});
// Test connection before saving
console.log(`Testing Microsoft OAuth connection for user ${session.user.id}`);
const testResult = await testEmailConnection(credentials);
if (!testResult.imap) {
return NextResponse.json(
{ error: `Connection test failed: ${testResult.error || 'Could not connect to Microsoft IMAP server'}` },
{ status: 400 }
);
}
// Save credentials to database and cache
console.log(`Saving Microsoft account for user: ${session.user.id}`);
await saveUserEmailCredentials(session.user.id, userEmail, credentials);
// Fetch the created account from the database
const createdAccount = await prisma.mailCredentials.findFirst({
where: { userId: session.user.id, email: userEmail },
select: {
id: true,
email: true,
display_name: true,
color: true,
}
});
// Invalidate any existing folder caches
await invalidateFolderCache(session.user.id, userEmail, '*');
// First cache the credentials in Redis to ensure OAuth data is saved
await cacheEmailCredentials(session.user.id, userEmail, credentials);
return NextResponse.json({
success: true,
account: createdAccount,
message: 'Microsoft account added successfully'
});
} catch (error) {
console.error('Error processing Microsoft callback:', error);
return NextResponse.json(
{
error: 'Failed to process Microsoft authentication',
details: error instanceof Error ? error.message : 'Unknown error'
},
{ status: 500 }
);
}
}

View File

@ -0,0 +1,41 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { getMicrosoftAuthUrl } from '@/lib/services/microsoft-oauth';
// Endpoint to initiate Microsoft OAuth flow
export async function GET(request: Request) {
try {
// Authenticate user
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
// Create a state parameter with the user's ID to prevent CSRF
const state = Buffer.from(JSON.stringify({
userId: session.user.id,
timestamp: Date.now()
})).toString('base64');
// Generate the authorization URL
const authUrl = getMicrosoftAuthUrl(state);
return NextResponse.json({
authUrl,
state
});
} catch (error) {
console.error('Error initiating Microsoft OAuth flow:', error);
return NextResponse.json(
{
error: 'Failed to initiate Microsoft OAuth flow',
details: error instanceof Error ? error.message : 'Unknown error'
},
{ status: 500 }
);
}
}

View File

@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth/next';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { forceRecacheUserCredentials } from '@/lib/services/email-service';
export async function GET(request: NextRequest) {
try {
// Get session to ensure user is authenticated
const session = await getServerSession(authOptions);
if (!session || !session.user?.id) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const userId = session.user.id;
// Force recache credentials
const success = await forceRecacheUserCredentials(userId);
if (success) {
return NextResponse.json({
success: true,
message: 'Credentials recached successfully',
userId
});
} else {
return NextResponse.json(
{
success: false,
error: 'Failed to recache credentials. Check server logs for details.',
userId
},
{ status: 500 }
);
}
} catch (error) {
console.error('Recache API error:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
},
{ status: 500 }
);
}
}

View File

@ -0,0 +1,69 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { getEmails } from '@/lib/services/email-service';
import { invalidateFolderCache } from '@/lib/redis';
import { refreshEmailsInBackground } from '@/lib/services/prefetch-service';
/**
* API endpoint to force refresh email data
* This is useful when the user wants to manually refresh or
* when the app detects that it's been a while since the last refresh
*/
export async function POST(request: Request) {
try {
// Authenticate user
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json(
{ error: "Not authenticated" },
{ status: 401 }
);
}
// Extract folder and account ID from request body
const { folder = 'INBOX', accountId } = await request.json();
// CRITICAL FIX: Proper folder and account ID handling
let normalizedFolder: string;
let effectiveAccountId: string;
if (folder.includes(':')) {
// Extract parts if folder already has a prefix
const parts = folder.split(':');
const folderAccountId = parts[0];
normalizedFolder = parts[1];
// If explicit accountId is provided, it takes precedence
effectiveAccountId = accountId || folderAccountId;
} else {
// No prefix in folder name
normalizedFolder = folder;
effectiveAccountId = accountId || 'default';
}
console.log(`[API] Refreshing folder=${normalizedFolder}, accountId=${effectiveAccountId}`);
// First invalidate the cache for this folder with the effective account ID
await invalidateFolderCache(session.user.id, effectiveAccountId, normalizedFolder);
// Then trigger a background refresh with explicit account ID
refreshEmailsInBackground(session.user.id, normalizedFolder, 1, 20, effectiveAccountId);
// Also prefetch page 2 if this is the inbox
if (normalizedFolder === 'INBOX') {
refreshEmailsInBackground(session.user.id, normalizedFolder, 2, 20, effectiveAccountId);
}
return NextResponse.json({
success: true,
message: `Refresh scheduled for folder: ${folder}`
});
} catch (error) {
console.error('Error scheduling refresh:', error);
return NextResponse.json(
{ error: "Failed to schedule refresh" },
{ status: 500 }
);
}
}

View File

@ -2,6 +2,14 @@ import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { getEmails } from '@/lib/services/email-service';
import {
getCachedEmailList,
cacheEmailList,
invalidateFolderCache
} from '@/lib/redis';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
// Simple in-memory cache (will be removed in a future update)
interface EmailCacheEntry {
@ -30,37 +38,67 @@ export async function GET(request: Request) {
const perPage = parseInt(searchParams.get("perPage") || "20");
const folder = searchParams.get("folder") || "INBOX";
const searchQuery = searchParams.get("search") || "";
const accountId = searchParams.get("accountId") || "";
const checkOnly = searchParams.get("checkOnly") === "true";
// Check cache - temporary until we implement a proper server-side cache
const cacheKey = `${session.user.id}:${folder}:${page}:${perPage}:${searchQuery}`;
const now = Date.now();
const cachedEmails = emailListCache[cacheKey];
if (cachedEmails && now - cachedEmails.timestamp < CACHE_TTL) {
console.log(`Using cached emails for ${cacheKey}`);
return NextResponse.json(cachedEmails.data);
// CRITICAL FIX: Log exact parameters received by the API
console.log(`[API] Received request with: folder=${folder}, accountId=${accountId}, page=${page}, checkOnly=${checkOnly}`);
// CRITICAL FIX: More robust parameter normalization
// 1. If folder contains an account prefix, extract it but DO NOT use it
// 2. Always prioritize the explicit accountId parameter
let normalizedFolder = folder;
let effectiveAccountId = accountId || 'default';
if (folder.includes(':')) {
const parts = folder.split(':');
const folderAccountId = parts[0];
normalizedFolder = parts[1];
console.log(`[API] Folder has prefix (${folderAccountId}), normalized to ${normalizedFolder}`);
// We intentionally DO NOT use folderAccountId here - the explicit accountId parameter takes precedence
}
// CRITICAL FIX: Enhanced logging for parameter resolution
console.log(`[API] Using normalized parameters: folder=${normalizedFolder}, accountId=${effectiveAccountId}`);
// Try to get from Redis cache first, but only if it's not a search query and not checkOnly
if (!searchQuery && !checkOnly) {
// CRITICAL FIX: Use consistent cache key format with the correct account ID
console.log(`[API] Checking Redis cache for ${session.user.id}:${effectiveAccountId}:${normalizedFolder}:${page}:${perPage}`);
const cachedEmails = await getCachedEmailList(
session.user.id,
effectiveAccountId, // Use effective account ID for consistent cache key
normalizedFolder, // Use normalized folder name without prefix
page,
perPage
);
if (cachedEmails) {
console.log(`[API] Using Redis cached emails for ${session.user.id}:${effectiveAccountId}:${normalizedFolder}:${page}:${perPage}`);
return NextResponse.json(cachedEmails);
}
}
console.log(`Cache miss for ${cacheKey}, fetching emails`);
console.log(`[API] Redis cache miss for ${session.user.id}:${effectiveAccountId}:${normalizedFolder}:${page}:${perPage}, fetching emails from IMAP`);
// Use the email service to fetch emails
// Use the email service to fetch emails with the normalized folder and effective account ID
// CRITICAL FIX: Pass parameters in the correct order and with proper values
const emailsResult = await getEmails(
session.user.id,
folder,
page,
perPage,
searchQuery
session.user.id, // userId
normalizedFolder, // folder (without prefix)
page, // page
perPage, // perPage
effectiveAccountId, // accountId
checkOnly // checkOnly flag - only check for new emails without loading full content
);
// Cache the results
emailListCache[cacheKey] = {
data: emailsResult,
timestamp: now
};
// CRITICAL FIX: Log when emails are returned from IMAP
console.log(`[API] Successfully fetched ${emailsResult.emails.length} emails from IMAP for account ${effectiveAccountId}`);
// The result is already cached in the getEmails function (if not checkOnly)
return NextResponse.json(emailsResult);
} catch (error: any) {
console.error("Error fetching emails:", error);
console.error("[API] Error fetching emails:", error);
return NextResponse.json(
{ error: "Failed to fetch emails", message: error.message },
{ status: 500 }
@ -75,25 +113,33 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { emailId, folderName } = await request.json();
const { emailId, folderName, accountId } = await request.json();
if (!emailId) {
return NextResponse.json({ error: 'Missing emailId parameter' }, { status: 400 });
}
// Invalidate cache entries for this folder or all folders if none specified
const userId = session.user.id;
Object.keys(emailListCache).forEach(key => {
if (folderName) {
if (key.includes(`${userId}:${folderName}`)) {
delete emailListCache[key];
}
} else {
if (key.startsWith(`${userId}:`)) {
delete emailListCache[key];
}
// Use account ID or default if not provided
const effectiveAccountId = accountId || 'default';
// Normalize folder name by removing account prefix if present
const normalizedFolder = folderName && folderName.includes(':')
? folderName.split(':')[1]
: folderName;
// Log the cache invalidation operation
console.log(`Invalidating cache for user ${session.user.id}, account ${effectiveAccountId}, folder ${normalizedFolder || 'all folders'}`);
// Invalidate Redis cache for the folder
if (normalizedFolder) {
await invalidateFolderCache(session.user.id, effectiveAccountId, normalizedFolder);
} else {
// If no folder specified, invalidate all folders (using a wildcard pattern)
const folders = ['INBOX', 'Sent', 'Drafts', 'Trash', 'Junk'];
for (const folder of folders) {
await invalidateFolderCache(session.user.id, effectiveAccountId, folder);
}
});
}
return NextResponse.json({ success: true });
} catch (error) {

View File

@ -0,0 +1,194 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { getMailboxes } from '@/lib/services/email-service';
import { getRedisClient } from '@/lib/redis';
import { getImapConnection } from '@/lib/services/email-service';
import { prisma } from '@/lib/prisma';
// Define extended MailCredentials type
interface MailCredentials {
id: string;
userId: string;
email: string;
password: string;
host: string;
port: number;
secure?: boolean;
smtp_host?: string | null;
smtp_port?: number | null;
smtp_secure?: boolean | null;
display_name?: string | null;
color?: string | null;
createdAt: Date;
updatedAt: Date;
}
// Keep track of last prefetch time for each user
const lastPrefetchMap = new Map<string, number>();
const PREFETCH_COOLDOWN_MS = 30000; // 30 seconds cooldown between prefetches
// Cache TTL for folders in Redis (5 minutes)
const FOLDERS_CACHE_TTL = 3600; // 1 hour
// Redis key for folders cache
const FOLDERS_CACHE_KEY = (userId: string, accountId: string) => `email:folders:${userId}:${accountId}`;
/**
* This endpoint is called when the app initializes to check if the user has email credentials
* and to start prefetching email data in the background if they do
*/
export async function GET() {
try {
// Get Redis connection first to ensure it's available
const redis = getRedisClient();
if (!redis) {
console.error('Redis connection failed');
return NextResponse.json({ error: 'Redis connection failed' }, { status: 500 });
}
// Get session with detailed logging
console.log('Attempting to get server session...');
const session = await getServerSession(authOptions);
if (!session) {
console.error('No session found');
return NextResponse.json({
authenticated: false,
error: 'No session found'
}, { status: 401 });
}
if (!session.user) {
console.error('No user in session');
return NextResponse.json({
authenticated: false,
error: 'No user in session'
}, { status: 401 });
}
if (!session.user.id) {
console.error('No user ID in session');
return NextResponse.json({
authenticated: false,
error: 'No user ID in session'
}, { status: 401 });
}
console.log('Session validated successfully:', {
userId: session.user.id,
email: session.user.email,
name: session.user.name
});
// Get user with their accounts
console.log('Fetching user with ID:', session.user.id);
const user = await prisma.user.findUnique({
where: { id: session.user.id },
include: { mailCredentials: true }
});
if (!user) {
console.error('User not found in database');
return NextResponse.json({
authenticated: true,
hasEmailCredentials: false,
error: 'User not found in database'
});
}
// Get all accounts for the user
const accounts = (user.mailCredentials || []) as MailCredentials[];
if (accounts.length === 0) {
console.log('No email accounts found for user:', session.user.id);
return NextResponse.json({
authenticated: true,
hasEmailCredentials: false,
accounts: [],
message: 'No email accounts found'
});
}
console.log(`Found ${accounts.length} accounts for user:`, accounts.map(a => a.email));
// Fetch folders for each account
const accountsWithFolders = await Promise.all(
accounts.map(async (account: MailCredentials) => {
const cacheKey = FOLDERS_CACHE_KEY(user.id, account.id);
try {
// Try to get folders from Redis cache first
const cachedFolders = await redis.get(cacheKey);
if (cachedFolders) {
console.log(`Using cached folders for account ${account.email}`);
return {
id: account.id,
email: account.email,
display_name: account.display_name,
color: account.color,
folders: JSON.parse(cachedFolders)
};
}
// If not in cache, fetch from IMAP
console.log(`Fetching folders from IMAP for account ${account.email}`);
const client = await getImapConnection(user.id, account.id);
if (!client) {
console.warn(`Failed to get IMAP connection for account ${account.email}`);
return {
id: account.id,
email: account.email,
display_name: account.display_name,
color: account.color,
folders: ['INBOX', 'Sent', 'Drafts', 'Trash', 'Junk']
};
}
const folders = await getMailboxes(client);
console.log(`Fetched ${folders.length} folders for account ${account.email}`);
// Cache the folders in Redis
await redis.set(
cacheKey,
JSON.stringify(folders),
'EX',
FOLDERS_CACHE_TTL
);
return {
id: account.id,
email: account.email,
display_name: account.display_name,
color: account.color,
folders
};
} catch (error) {
console.error(`Error fetching folders for account ${account.id}:`, error);
return {
id: account.id,
email: account.email,
display_name: account.display_name,
color: account.color,
folders: ['INBOX', 'Sent', 'Drafts', 'Trash', 'Junk']
};
}
})
);
return NextResponse.json({
authenticated: true,
hasEmailCredentials: true,
allAccounts: accountsWithFolders
});
} catch (error) {
console.error('Error in session route:', error);
return NextResponse.json(
{
authenticated: false,
error: 'Internal server error',
details: error instanceof Error ? error.message : 'Unknown error'
},
{ status: 500 }
);
}
}

View File

@ -0,0 +1,137 @@
import { NextResponse } from 'next/server';
import { ImapFlow } from 'imapflow';
import nodemailer from 'nodemailer';
export async function POST(request: Request) {
try {
// Parse request body
const body = await request.json().catch(e => {
console.error('Error parsing request body:', e);
return {};
});
// Log request but hide password
console.log('Testing connection with:', {
...body,
password: body.password ? '***' : undefined
});
const { email, password, host, port, secure = true } = body;
// Validate required fields
if (!email || !password || !host || !port) {
const missing = [];
if (!email) missing.push('email');
if (!password) missing.push('password');
if (!host) missing.push('host');
if (!port) missing.push('port');
return NextResponse.json(
{ error: `Missing required fields: ${missing.join(', ')}` },
{ status: 400 }
);
}
// Fix common hostname errors - strip http/https prefixes
let cleanHost = host;
if (cleanHost.startsWith('http://')) {
cleanHost = cleanHost.substring(7);
} else if (cleanHost.startsWith('https://')) {
cleanHost = cleanHost.substring(8);
}
console.log(`Testing IMAP connection to ${cleanHost}:${port} for ${email}`);
// Test IMAP connection
const client = new ImapFlow({
host: cleanHost,
port: typeof port === 'string' ? parseInt(port) : port,
secure: secure === true || secure === 'true',
auth: {
user: email,
pass: password,
},
logger: false,
tls: {
rejectUnauthorized: false
},
// Set timeout to prevent long waits
connectionTimeout: 10000
});
try {
await client.connect();
console.log(`IMAP connection successful for ${email}`);
// Try to list mailboxes
const mailboxes = await client.list();
const folderNames = mailboxes.map(mailbox => mailbox.path);
console.log(`Found ${folderNames.length} folders:`, folderNames.slice(0, 5));
try {
await client.logout();
} catch (e) {
// Ignore logout errors
}
return NextResponse.json({
success: true,
message: 'IMAP connection successful',
details: {
host: cleanHost,
port,
folderCount: folderNames.length,
sampleFolders: folderNames.slice(0, 5)
}
});
} catch (error) {
console.error('IMAP connection test failed:', error);
let friendlyMessage = 'Connection failed';
let errorDetails = '';
if (error instanceof Error) {
errorDetails = error.message;
if (error.message.includes('Invalid login') || error.message.includes('authentication failed')) {
friendlyMessage = 'Invalid username or password';
} else if (error.message.includes('ENOTFOUND') || error.message.includes('ECONNREFUSED')) {
friendlyMessage = 'Cannot connect to server - check host and port';
} else if (error.message.includes('certificate')) {
friendlyMessage = 'SSL/TLS certificate issue';
} else if (error.message.includes('timeout')) {
friendlyMessage = 'Connection timed out';
}
}
try {
await client.logout();
} catch (e) {
// Ignore logout errors
}
return NextResponse.json(
{
error: friendlyMessage,
details: errorDetails,
debug: {
providedHost: host,
cleanHost,
port,
secure
}
},
{ status: 400 }
);
}
} catch (error) {
console.error('Error testing connection:', error);
return NextResponse.json(
{
error: 'Failed to test connection',
details: error instanceof Error ? error.message : 'Unknown error'
},
{ status: 500 }
);
}
}

View File

@ -0,0 +1,240 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { getImapConnection } from '@/lib/services/email-service';
import { prisma } from '@/lib/prisma';
import { getRedisClient } from '@/lib/redis';
// Cache TTL for unread counts (increased to 2 minutes for better performance)
const UNREAD_COUNTS_CACHE_TTL = 120;
// Key for unread counts cache
const UNREAD_COUNTS_CACHE_KEY = (userId: string) => `email:unread:${userId}`;
// Refresh lock key to prevent parallel refreshes
const REFRESH_LOCK_KEY = (userId: string) => `email:unread-refresh:${userId}`;
// Lock TTL to prevent stuck locks (30 seconds)
const REFRESH_LOCK_TTL = 30;
/**
* API route for fetching unread counts for email folders
* Optimized with proper caching, connection reuse, and background refresh
*/
export async function GET(request: Request) {
try {
// Authenticate user
const session = await getServerSession(authOptions);
if (!session || !session.user?.id) {
return NextResponse.json(
{ error: "Not authenticated" },
{ status: 401 }
);
}
const userId = session.user.id;
const redis = getRedisClient();
// First try to get from cache
const cachedCounts = await redis.get(UNREAD_COUNTS_CACHE_KEY(userId));
if (cachedCounts) {
// Use cached results if available
console.log(`[UNREAD_API] Using cached unread counts for user ${userId}`);
// If the cache is about to expire, schedule a background refresh
const ttl = await redis.ttl(UNREAD_COUNTS_CACHE_KEY(userId));
if (ttl < UNREAD_COUNTS_CACHE_TTL / 2) {
// Only refresh if not already refreshing (use a lock)
const lockAcquired = await redis.set(
REFRESH_LOCK_KEY(userId),
Date.now().toString(),
'EX',
REFRESH_LOCK_TTL,
'NX' // Set only if key doesn't exist
);
if (lockAcquired) {
console.log(`[UNREAD_API] Scheduling background refresh for user ${userId}`);
// Use Promise to run in background
setTimeout(() => {
refreshUnreadCounts(userId, redis)
.catch(err => console.error(`[UNREAD_API] Background refresh error: ${err}`))
.finally(() => {
// Release lock regardless of outcome
redis.del(REFRESH_LOCK_KEY(userId)).catch(() => {});
});
}, 0);
}
}
return NextResponse.json(JSON.parse(cachedCounts));
}
console.log(`[UNREAD_API] Cache miss for user ${userId}, fetching unread counts`);
// Try to acquire lock to prevent parallel refreshes
const lockAcquired = await redis.set(
REFRESH_LOCK_KEY(userId),
Date.now().toString(),
'EX',
REFRESH_LOCK_TTL,
'NX' // Set only if key doesn't exist
);
if (!lockAcquired) {
console.log(`[UNREAD_API] Another process is refreshing unread counts for ${userId}`);
// Return empty counts with short cache time if we can't acquire lock
// The next request will likely get cached data
return NextResponse.json({ _status: 'pending_refresh' });
}
try {
// Fetch new counts
const unreadCounts = await fetchUnreadCounts(userId);
// Save to cache with longer TTL (2 minutes)
await redis.set(
UNREAD_COUNTS_CACHE_KEY(userId),
JSON.stringify(unreadCounts),
'EX',
UNREAD_COUNTS_CACHE_TTL
);
return NextResponse.json(unreadCounts);
} finally {
// Always release lock
await redis.del(REFRESH_LOCK_KEY(userId));
}
} catch (error: any) {
console.error("[UNREAD_API] Error fetching unread counts:", error);
return NextResponse.json(
{ error: "Failed to fetch unread counts", message: error.message },
{ status: 500 }
);
}
}
/**
* Background refresh function to update cache without blocking the API response
*/
async function refreshUnreadCounts(userId: string, redis: any): Promise<void> {
try {
console.log(`[UNREAD_API] Background refresh started for user ${userId}`);
const unreadCounts = await fetchUnreadCounts(userId);
// Save to cache
await redis.set(
UNREAD_COUNTS_CACHE_KEY(userId),
JSON.stringify(unreadCounts),
'EX',
UNREAD_COUNTS_CACHE_TTL
);
console.log(`[UNREAD_API] Background refresh completed for user ${userId}`);
} catch (error) {
console.error(`[UNREAD_API] Background refresh failed for user ${userId}:`, error);
throw error;
}
}
/**
* Core function to fetch unread counts from IMAP
*/
async function fetchUnreadCounts(userId: string): Promise<Record<string, Record<string, number>>> {
// Get all accounts from the database directly
const accounts = await prisma.mailCredentials.findMany({
where: { userId },
select: {
id: true,
email: true
}
});
console.log(`[UNREAD_API] Found ${accounts.length} accounts for user ${userId}`);
if (accounts.length === 0) {
return { default: {} };
}
// Mapping to hold the unread counts
const unreadCounts: Record<string, Record<string, number>> = {};
// For each account, get the unread counts for standard folders
for (const account of accounts) {
const accountId = account.id;
try {
// Get IMAP connection for this account
console.log(`[UNREAD_API] Processing account ${accountId} (${account.email})`);
const client = await getImapConnection(userId, accountId);
unreadCounts[accountId] = {};
// Standard folders to check
const standardFolders = ['INBOX', 'Sent', 'Drafts', 'Trash', 'Junk', 'Spam', 'Archive', 'Sent Items', 'Archives', 'Notes', 'Éléments supprimés'];
// Get mailboxes for this account to check if folders exist
const mailboxes = await client.list();
const availableFolders = mailboxes.map(mb => mb.path);
// Check each standard folder if it exists
for (const folder of standardFolders) {
// Skip if folder doesn't exist in this account
if (!availableFolders.includes(folder) &&
!availableFolders.some(f => f.toLowerCase() === folder.toLowerCase())) {
continue;
}
try {
// Check folder status without opening it (more efficient)
const status = await client.status(folder, { unseen: true });
if (status && typeof status.unseen === 'number') {
// Store the unread count
unreadCounts[accountId][folder] = status.unseen;
// Also store with prefixed version for consistency
unreadCounts[accountId][`${accountId}:${folder}`] = status.unseen;
console.log(`[UNREAD_API] Account ${accountId}, folder ${folder}: ${status.unseen} unread`);
}
} catch (folderError) {
console.error(`[UNREAD_API] Error getting unread count for ${accountId}:${folder}:`, folderError);
// Continue to next folder even if this one fails
}
}
// Don't close the connection - let the connection pool handle it
} catch (accountError) {
console.error(`[UNREAD_API] Error processing account ${accountId}:`, accountError);
}
}
return unreadCounts;
}
/**
* Helper to get all account IDs for a user
*/
async function getUserAccountIds(userId: string): Promise<string[]> {
try {
// Get credentials for all accounts from the email service
// This is a simplified version - you should replace this with your actual logic
// to retrieve the user's accounts
// First try the default account
const defaultClient = await getImapConnection(userId, 'default');
const accounts = ['default'];
try {
// Try to get other accounts if they exist
// This is just a placeholder - implement your actual account retrieval logic
// Close the default connection
await defaultClient.logout();
} catch (error) {
console.error('[UNREAD_API] Error getting additional accounts:', error);
}
return accounts;
} catch (error) {
console.error('[UNREAD_API] Error getting account IDs:', error);
return ['default']; // Return at least the default account
}
}

View File

@ -1,99 +0,0 @@
import { NextResponse } from 'next/server';
import { ImapFlow } from 'imapflow';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { prisma } from '@/lib/prisma';
export async function GET(request: Request, { params }: { params: { id: string } }) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
// Get credentials from database
const credentials = await prisma.mailCredentials.findUnique({
where: {
userId: session.user.id
}
});
if (!credentials) {
return NextResponse.json(
{ error: 'No mail credentials found. Please configure your email account.' },
{ status: 401 }
);
}
// Get the current folder from the request URL
const url = new URL(request.url);
const folder = url.searchParams.get('folder') || 'INBOX';
// Connect to IMAP server
const client = new ImapFlow({
host: credentials.host,
port: credentials.port,
secure: true,
auth: {
user: credentials.email,
pass: credentials.password,
},
logger: false,
emitLogs: false,
tls: {
rejectUnauthorized: false
}
});
try {
await client.connect();
await client.mailboxOpen(folder);
// Fetch the full email content
const message = await client.fetchOne(params.id, {
source: true,
envelope: true,
flags: true
});
if (!message) {
return NextResponse.json(
{ error: 'Email not found' },
{ status: 404 }
);
}
// Extract email content
const result = {
id: message.uid.toString(),
from: message.envelope.from[0].address,
subject: message.envelope.subject || '(No subject)',
date: message.envelope.date.toISOString(),
read: message.flags.has('\\Seen'),
starred: message.flags.has('\\Flagged'),
folder: folder,
body: message.source.toString(),
to: message.envelope.to?.map(addr => addr.address).join(', ') || '',
cc: message.envelope.cc?.map(addr => addr.address).join(', ') || '',
bcc: message.envelope.bcc?.map(addr => addr.address).join(', ') || '',
};
return NextResponse.json(result);
} finally {
try {
await client.logout();
} catch (e) {
console.error('Error during logout:', e);
}
}
} catch (error) {
console.error('Error fetching email:', error);
return NextResponse.json(
{ error: 'An unexpected error occurred' },
{ status: 500 }
);
}
}

View File

@ -1,172 +0,0 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import Imap from 'imap';
interface StoredCredentials {
email: string;
password: string;
host: string;
port: number;
}
function getStoredCredentials(): StoredCredentials | null {
const cookieStore = cookies();
const credentialsCookie = cookieStore.get('imap_credentials');
if (!credentialsCookie?.value) {
return null;
}
try {
const credentials = JSON.parse(credentialsCookie.value);
if (!credentials.email || !credentials.password || !credentials.host || !credentials.port) {
return null;
}
return credentials;
} catch (error) {
return null;
}
}
export async function POST(request: Request) {
try {
const { emailIds, action } = await request.json();
if (!emailIds || !Array.isArray(emailIds) || !action) {
return NextResponse.json(
{ error: 'Invalid request parameters' },
{ status: 400 }
);
}
// Get the current folder from the request URL
const url = new URL(request.url);
const folder = url.searchParams.get('folder') || 'INBOX';
// Get stored credentials
const credentials = getStoredCredentials();
if (!credentials) {
return NextResponse.json(
{ error: 'No stored credentials found' },
{ status: 401 }
);
}
return new Promise((resolve) => {
const imap = new Imap({
user: credentials.email,
password: credentials.password,
host: credentials.host,
port: credentials.port,
tls: true,
tlsOptions: { rejectUnauthorized: false },
authTimeout: 30000,
connTimeout: 30000
});
const timeout = setTimeout(() => {
console.error('IMAP connection timeout');
imap.end();
resolve(NextResponse.json({ error: 'Connection timeout' }));
}, 30000);
imap.once('error', (err: Error) => {
console.error('IMAP error:', err);
clearTimeout(timeout);
resolve(NextResponse.json({ error: 'IMAP connection error' }));
});
imap.once('ready', () => {
imap.openBox(folder, false, (err, box) => {
if (err) {
console.error(`Error opening box ${folder}:`, err);
clearTimeout(timeout);
imap.end();
resolve(NextResponse.json({ error: `Failed to open folder ${folder}` }));
return;
}
// Convert string IDs to numbers
const numericIds = emailIds.map(id => parseInt(id, 10));
// Process each email
let processedCount = 0;
const totalEmails = numericIds.length;
const processNextEmail = (index: number) => {
if (index >= totalEmails) {
clearTimeout(timeout);
imap.end();
resolve(NextResponse.json({ success: true }));
return;
}
const id = numericIds[index];
const fetch = imap.fetch(id.toString(), {
bodies: '',
struct: true
});
fetch.on('message', (msg) => {
msg.once('attributes', (attrs) => {
const uid = attrs.uid;
if (!uid) {
processedCount++;
processNextEmail(index + 1);
return;
}
switch (action) {
case 'delete':
imap.move(uid, 'Trash', (err) => {
if (err) console.error('Error moving to trash:', err);
processedCount++;
processNextEmail(index + 1);
});
break;
case 'mark-read':
imap.addFlags(uid, ['\\Seen'], (err) => {
if (err) console.error('Error marking as read:', err);
processedCount++;
processNextEmail(index + 1);
});
break;
case 'mark-unread':
imap.removeFlags(uid, ['\\Seen'], (err) => {
if (err) console.error('Error marking as unread:', err);
processedCount++;
processNextEmail(index + 1);
});
break;
case 'archive':
imap.move(uid, 'Archive', (err) => {
if (err) console.error('Error moving to archive:', err);
processedCount++;
processNextEmail(index + 1);
});
break;
}
});
});
fetch.on('error', (err) => {
console.error('Error fetching email:', err);
processedCount++;
processNextEmail(index + 1);
});
};
processNextEmail(0);
});
});
imap.connect();
});
} catch (error) {
console.error('Error in bulk actions:', error);
return NextResponse.json(
{ error: 'Failed to perform bulk action' },
{ status: 500 }
);
}
}

View File

@ -1,154 +0,0 @@
import { NextResponse } from 'next/server';
import { ImapFlow } from 'imapflow';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { prisma } from '@/lib/prisma';
export async function POST(request: Request) {
try {
const session = await getServerSession(authOptions);
console.log('Session in mail login:', session);
if (!session?.user?.id) {
console.error('No user ID in session');
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
// Verify user exists
console.log('Checking for user with ID:', session.user.id);
const user = await prisma.user.findUnique({
where: { id: session.user.id }
});
console.log('User found in database:', user);
if (!user) {
console.error('User not found in database');
return NextResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
}
const { email, password, host, port } = await request.json();
if (!email || !password || !host || !port) {
return NextResponse.json(
{ error: 'Missing required fields' },
{ status: 400 }
);
}
// Test IMAP connection
const client = new ImapFlow({
host: host,
port: parseInt(port),
secure: true,
auth: {
user: email,
pass: password,
},
logger: false,
emitLogs: false,
tls: {
rejectUnauthorized: false // Allow self-signed certificates
}
});
try {
await client.connect();
await client.mailboxOpen('INBOX');
// Store or update credentials in database
await prisma.mailCredentials.upsert({
where: {
userId: session.user.id
},
update: {
email,
password,
host,
port: parseInt(port)
},
create: {
userId: session.user.id,
email,
password,
host,
port: parseInt(port)
}
});
return NextResponse.json({ success: true });
} catch (error) {
if (error instanceof Error) {
if (error.message.includes('Invalid login')) {
return NextResponse.json(
{ error: 'Invalid login or password' },
{ status: 401 }
);
}
return NextResponse.json(
{ error: `IMAP connection error: ${error.message}` },
{ status: 500 }
);
}
return NextResponse.json(
{ error: 'Failed to connect to email server' },
{ status: 500 }
);
} finally {
try {
await client.logout();
} catch (e) {
console.error('Error during logout:', e);
}
}
} catch (error) {
console.error('Error in login handler:', error);
return NextResponse.json(
{ error: 'An unexpected error occurred' },
{ status: 500 }
);
}
}
export async function GET() {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const credentials = await prisma.mailCredentials.findUnique({
where: {
userId: session.user.id
},
select: {
email: true,
host: true,
port: true
}
});
if (!credentials) {
return NextResponse.json(
{ error: 'No stored credentials found' },
{ status: 404 }
);
}
return NextResponse.json(credentials);
} catch (error) {
return NextResponse.json(
{ error: 'Failed to retrieve credentials' },
{ status: 500 }
);
}
}

View File

@ -1,91 +0,0 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { getImapClient } from '@/lib/imap';
import { ImapFlow } from 'imapflow';
export async function POST(request: Request) {
let client: ImapFlow | null = null;
try {
// Get the session and validate it
const session = await getServerSession(authOptions);
console.log('Session:', session); // Debug log
if (!session?.user?.id) {
console.error('No session or user ID found');
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
// Get the request body
const { emailId, isRead } = await request.json();
console.log('Request body:', { emailId, isRead }); // Debug log
if (!emailId || typeof isRead !== 'boolean') {
console.error('Invalid request parameters:', { emailId, isRead });
return NextResponse.json(
{ error: 'Invalid request parameters' },
{ status: 400 }
);
}
// Get the current folder from the request URL
const url = new URL(request.url);
const folder = url.searchParams.get('folder') || 'INBOX';
console.log('Folder:', folder); // Debug log
try {
// Initialize IMAP client with user credentials
client = await getImapClient();
if (!client) {
console.error('Failed to initialize IMAP client');
return NextResponse.json(
{ error: 'Failed to initialize email client' },
{ status: 500 }
);
}
await client.connect();
await client.mailboxOpen(folder);
// Fetch the email to get its UID
const message = await client.fetchOne(emailId.toString(), {
uid: true,
flags: true
});
if (!message) {
console.error('Email not found:', emailId);
return NextResponse.json(
{ error: 'Email not found' },
{ status: 404 }
);
}
// Update the flags
if (isRead) {
await client.messageFlagsAdd(message.uid.toString(), ['\\Seen'], { uid: true });
} else {
await client.messageFlagsRemove(message.uid.toString(), ['\\Seen'], { uid: true });
}
return NextResponse.json({ success: true });
} finally {
if (client) {
try {
await client.logout();
} catch (e) {
console.error('Error during logout:', e);
}
}
}
} catch (error) {
console.error('Error marking email as read:', error);
return NextResponse.json(
{ error: 'Failed to mark email as read' },
{ status: 500 }
);
}
}

View File

@ -1,22 +0,0 @@
import { NextResponse } from 'next/server';
/**
* This route is deprecated. It redirects to the new courrier API endpoint.
* @deprecated Use the /api/courrier endpoint instead
*/
export async function GET(request: Request) {
console.warn('Deprecated: /api/mail route is being used. Update your code to use /api/courrier instead.');
// Extract query parameters
const url = new URL(request.url);
// Redirect to the new API endpoint
const redirectUrl = new URL('/api/courrier', url.origin);
// Copy all search parameters
url.searchParams.forEach((value, key) => {
redirectUrl.searchParams.set(key, value);
});
return NextResponse.redirect(redirectUrl.toString());
}

View File

@ -1,35 +0,0 @@
import { NextResponse } from 'next/server';
/**
* This route is deprecated. It redirects to the new courrier API endpoint.
* @deprecated Use the /api/courrier/send endpoint instead
*/
export async function POST(request: Request) {
console.warn('Deprecated: /api/mail/send route is being used. Update your code to use /api/courrier/send instead.');
try {
// Clone the request body
const body = await request.json();
// Make a new request to the courrier API
const newRequest = new Request(new URL('/api/courrier/send', request.url).toString(), {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
// Forward the request
const response = await fetch(newRequest);
const data = await response.json();
return NextResponse.json(data, { status: response.status });
} catch (error) {
console.error('Error forwarding to courrier/send:', error);
return NextResponse.json(
{ error: 'Failed to send email' },
{ status: 500 }
);
}
}

View File

@ -1,56 +0,0 @@
import { NextResponse } from 'next/server';
import Imap from 'imap';
export async function POST(request: Request) {
try {
const { email, password, host, port } = await request.json();
if (!email || !password || !host || !port) {
return NextResponse.json(
{ error: 'Missing required fields' },
{ status: 400 }
);
}
const imapConfig = {
user: email,
password,
host,
port: parseInt(port),
tls: true,
authTimeout: 10000,
connTimeout: 10000,
debug: (info: string) => console.log('IMAP Debug:', info)
};
console.log('Testing IMAP connection with config:', {
...imapConfig,
password: '***',
email
});
const imap = new Imap(imapConfig);
const connectPromise = new Promise((resolve, reject) => {
imap.once('ready', () => {
imap.end();
resolve(true);
});
imap.once('error', (err: Error) => {
imap.end();
reject(err);
});
imap.connect();
});
await connectPromise;
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error testing connection:', error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to connect to email server' },
{ status: 500 }
);
}
}

View File

@ -1,72 +1,72 @@
import { NextResponse } from 'next/server';
import { simpleParser, AddressObject } from 'mailparser';
import { NextRequest, NextResponse } from 'next/server';
import { parseEmail } from '@/lib/server/email-parser';
import { sanitizeHtml } from '@/lib/utils/dom-purify-config';
function getEmailAddress(address: AddressObject | AddressObject[] | undefined): string | null {
if (!address) return null;
if (Array.isArray(address)) {
return address.map(a => a.text).join(', ');
}
return address.text;
interface EmailAddress {
name?: string;
address: string;
}
// Clean up the HTML to make it safe but preserve styles
function processHtml(html: string | null): string | null {
if (!html) return null;
// Helper to extract email addresses from mailparser Address objects
function getEmailAddresses(addresses: any): EmailAddress[] {
if (!addresses) return [];
try {
// Make the content display well in the email context
return html
// Fix self-closing tags that might break React
.replace(/<(br|hr|img|input|link|meta|area|base|col|embed|keygen|param|source|track|wbr)([^>]*)>/gi, '<$1$2 />')
// Keep style tags but ensure they're closed properly
.replace(/<style([^>]*)>([\s\S]*?)<\/style>/gi, (match) => {
// Just return the matched style tag as-is
return match;
});
} catch (error) {
console.error('Error processing HTML:', error);
return html;
// Handle various address formats
if (Array.isArray(addresses)) {
return addresses.map(addr => ({
name: addr.name || undefined,
address: addr.address
}));
}
if (typeof addresses === 'object') {
const result: EmailAddress[] = [];
// Handle mailparser format with text, html, value properties
if (addresses.value) {
addresses.value.forEach((addr: any) => {
result.push({
name: addr.name || undefined,
address: addr.address
});
});
return result;
}
// Handle direct object with address property
if (addresses.address) {
return [{
name: addresses.name || undefined,
address: addresses.address
}];
}
}
return [];
}
export async function POST(request: Request) {
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { email } = body;
if (!email || typeof email !== 'string') {
if (!body.email) {
return NextResponse.json(
{ error: 'Invalid email content' },
{ error: 'Missing email content' },
{ status: 400 }
);
}
const parsed = await simpleParser(email);
const parsedEmail = await parseEmail(body.email);
// Process the HTML to preserve styling but make it safe
// Handle the case where parsed.html could be a boolean
const processedHtml = typeof parsed.html === 'string' ? processHtml(parsed.html) : null;
// Process HTML content if available
if (parsedEmail.html) {
parsedEmail.html = sanitizeHtml(parsedEmail.html);
}
return NextResponse.json({
subject: parsed.subject || null,
from: getEmailAddress(parsed.from),
to: getEmailAddress(parsed.to),
cc: getEmailAddress(parsed.cc),
bcc: getEmailAddress(parsed.bcc),
date: parsed.date || null,
html: processedHtml,
text: parsed.textAsHtml || parsed.text || null,
attachments: parsed.attachments?.map(att => ({
filename: att.filename,
contentType: att.contentType,
size: att.size
})) || [],
headers: parsed.headers || {}
});
return NextResponse.json(parsedEmail);
} catch (error) {
console.error('Error parsing email:', error);
return NextResponse.json(
{ error: 'Failed to parse email' },
{ error: 'Failed to parse email', details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}

View File

@ -0,0 +1,168 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth/next';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
// Map of service prefixes to their base URLs
const SERVICE_URLS: Record<string, string> = {
'parole': process.env.NEXT_PUBLIC_IFRAME_PAROLE_URL || '',
'alma': process.env.NEXT_PUBLIC_IFRAME_AI_ASSISTANT_URL || '',
'crm': process.env.NEXT_PUBLIC_IFRAME_MEDIATIONS_URL || '',
'vision': process.env.NEXT_PUBLIC_IFRAME_CONFERENCE_URL || '',
'showcase': process.env.NEXT_PUBLIC_IFRAME_SHOWCASE_URL || '',
'agilite': process.env.NEXT_PUBLIC_IFRAME_AGILITY_URL || '',
'dossiers': process.env.NEXT_PUBLIC_IFRAME_DRIVE_URL || '',
'the-message': process.env.NEXT_PUBLIC_IFRAME_THEMESSAGE_URL || '',
'qg': process.env.NEXT_PUBLIC_IFRAME_MISSIONVIEW_URL || '',
'design': process.env.NEXT_PUBLIC_IFRAME_DESIGN_URL || '',
'artlab': process.env.NEXT_PUBLIC_IFRAME_DESIGN_URL || ''
};
// Check if a service is Rocket.Chat (they require special authentication)
function isRocketChat(serviceName: string): boolean {
return serviceName === 'parole'; // Assuming 'parole' is your Rocket.Chat service
}
export async function GET(
request: NextRequest,
context: { params: { path: string[] } }
) {
// Get the service prefix (first part of the path)
const paramsObj = await Promise.resolve(context.params);
const pathArray = await Promise.resolve(paramsObj.path);
const serviceName = pathArray[0];
const restOfPath = pathArray.slice(1).join('/');
// Get the base URL for this service
const baseUrl = SERVICE_URLS[serviceName];
if (!baseUrl) {
return NextResponse.json({ error: 'Service not found' }, { status: 404 });
}
// Get the user's session
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
// Extract search parameters
const searchParams = new URL(request.url).searchParams.toString();
const targetUrl = `${baseUrl}/${restOfPath}${searchParams ? `?${searchParams}` : ''}`;
// Prepare headers based on the service type
const headers: Record<string, string> = {};
if (isRocketChat(serviceName)) {
// For Rocket.Chat, use their specific authentication headers
if (session.rocketChatToken && session.rocketChatUserId) {
console.log('Using Rocket.Chat specific authentication');
headers['X-Auth-Token'] = session.rocketChatToken;
headers['X-User-Id'] = session.rocketChatUserId;
} else {
console.warn('Rocket.Chat tokens not available in session');
// Still try with standard authorization if available
if (session.accessToken) {
headers['Authorization'] = `Bearer ${session.accessToken}`;
}
}
} else {
// Standard OAuth Bearer token for other services
if (session.accessToken) {
headers['Authorization'] = `Bearer ${session.accessToken}`;
}
}
// Add other common headers
headers['Accept'] = 'application/json, text/html, */*';
// Forward the request to the target service with the authentication headers
const response = await fetch(targetUrl, { headers });
// Get response data
const data = await response.arrayBuffer();
// Create response with the same status and headers
const newResponse = new NextResponse(data, {
status: response.status,
statusText: response.statusText,
headers: {
'Content-Type': response.headers.get('Content-Type') || 'application/octet-stream',
}
});
return newResponse;
} catch (error) {
console.error('Proxy error:', error);
return NextResponse.json({ error: 'Proxy error' }, { status: 500 });
}
}
export async function POST(
request: NextRequest,
context: { params: { path: string[] } }
) {
// Get the service prefix (first part of the path)
const paramsObj = await Promise.resolve(context.params);
const pathArray = await Promise.resolve(paramsObj.path);
const serviceName = pathArray[0];
const restOfPath = pathArray.slice(1).join('/');
const baseUrl = SERVICE_URLS[serviceName];
if (!baseUrl) {
return NextResponse.json({ error: 'Service not found' }, { status: 404 });
}
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
const searchParams = new URL(request.url).searchParams.toString();
const targetUrl = `${baseUrl}/${restOfPath}${searchParams ? `?${searchParams}` : ''}`;
// Get the request body
const body = await request.arrayBuffer();
// Prepare headers based on the service type
const headers: Record<string, string> = {
'Content-Type': request.headers.get('Content-Type') || 'application/json',
};
if (isRocketChat(serviceName)) {
// For Rocket.Chat, use their specific authentication headers
if (session.rocketChatToken && session.rocketChatUserId) {
headers['X-Auth-Token'] = session.rocketChatToken;
headers['X-User-Id'] = session.rocketChatUserId;
} else if (session.accessToken) {
headers['Authorization'] = `Bearer ${session.accessToken}`;
}
} else {
// Standard OAuth Bearer token for other services
if (session.accessToken) {
headers['Authorization'] = `Bearer ${session.accessToken}`;
}
}
const response = await fetch(targetUrl, {
method: 'POST',
headers,
body: body
});
const data = await response.arrayBuffer();
return new NextResponse(data, {
status: response.status,
statusText: response.statusText,
headers: {
'Content-Type': response.headers.get('Content-Type') || 'application/octet-stream',
}
});
} catch (error) {
console.error('Proxy error:', error);
return NextResponse.json({ error: 'Proxy error' }, { status: 500 });
}
}

View File

@ -0,0 +1,24 @@
import { NextResponse } from 'next/server';
import { getRedisStatus } from '@/lib/redis';
/**
* API route to check Redis connection status
* Used for monitoring and debugging
*/
export async function GET() {
try {
const status = await getRedisStatus();
return NextResponse.json({
ready: status.status === 'connected',
status: status.status,
ping: status.ping,
error: status.error
});
} catch (error) {
return NextResponse.json({
ready: false,
error: error instanceof Error ? error.message : 'Unknown error'
}, { status: 500 });
}
}

View File

@ -0,0 +1,72 @@
import { NextResponse } from 'next/server';
export async function GET() {
try {
// Check if we have the required environment variables
const token = process.env.ROCKET_CHAT_TOKEN;
const userId = process.env.ROCKET_CHAT_USER_ID;
const baseUrl = process.env.NEXT_PUBLIC_IFRAME_PAROLE_URL?.split('/channel')[0];
if (!token || !userId) {
return NextResponse.json({
error: 'Missing Rocket.Chat admin credentials',
hasToken: !!token,
hasUserId: !!userId
}, { status: 500 });
}
if (!baseUrl) {
return NextResponse.json({
error: 'Missing Rocket.Chat base URL',
iframeUrl: process.env.NEXT_PUBLIC_IFRAME_PAROLE_URL
}, { status: 500 });
}
// Test a simple API call to verify credentials
const adminHeaders = {
'X-Auth-Token': token,
'X-User-Id': userId,
'Content-Type': 'application/json'
};
// Get server info (public endpoint that still requires admin auth)
const infoResponse = await fetch(`${baseUrl}/api/v1/info`, {
method: 'GET',
headers: adminHeaders
});
if (!infoResponse.ok) {
return NextResponse.json({
error: 'Failed to connect to Rocket.Chat API',
status: infoResponse.status,
statusText: infoResponse.statusText,
baseUrl
}, { status: 500 });
}
const infoData = await infoResponse.json();
// Try to list users (needs admin permissions)
const usersResponse = await fetch(`${baseUrl}/api/v1/users.list?count=5`, {
method: 'GET',
headers: adminHeaders
});
const usersResult = await usersResponse.json();
return NextResponse.json({
success: true,
serverInfo: {
version: infoData.version,
serverRunning: infoData.success
},
usersCount: usersResult.users?.length || 0,
baseUrl
});
} catch (error) {
return NextResponse.json({
error: 'Error testing Rocket.Chat connection',
message: error instanceof Error ? error.message : 'Unknown error'
}, { status: 500 });
}
}

View File

@ -1,8 +1,6 @@
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { getKeycloakAdminClient } from "@/lib/keycloak";
import { RoleRepresentation } from "@keycloak/keycloak-admin-client/lib/defs/roleRepresentation";
export async function GET(
request: Request,
@ -14,21 +12,11 @@ export async function GET(
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { userId } = params;
const kcAdminClient = await getKeycloakAdminClient();
// Get all available roles
const availableRoles = await kcAdminClient.roles.find();
// Get user's current roles
const userRoles = await kcAdminClient.users.listRoleMappings({
id: userId,
});
return NextResponse.json({
availableRoles,
userRoles,
});
// Keycloak functionality has been removed
return NextResponse.json(
{ error: "This functionality is not available" },
{ status: 501 }
);
} catch (error) {
console.error("Error fetching roles:", error);
return NextResponse.json(
@ -48,46 +36,11 @@ export async function PUT(
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { userId } = params;
const { roles } = await request.json();
const kcAdminClient = await getKeycloakAdminClient();
// Get all available roles
const availableRoles = await kcAdminClient.roles.find();
// Get current user roles
const currentRoles = await kcAdminClient.users.listRoleMappings({
id: userId,
});
// Find roles to add and remove
const rolesToAdd = roles.filter(
(role: string) => !currentRoles.realmMappings?.some((r: RoleRepresentation) => r.name === role)
// Keycloak functionality has been removed
return NextResponse.json(
{ error: "This functionality is not available" },
{ status: 501 }
);
const rolesToRemove = currentRoles.realmMappings?.filter(
(role: RoleRepresentation) => !roles.includes(role.name)
);
// Add new roles
for (const roleName of rolesToAdd) {
const role = availableRoles.find((r: RoleRepresentation) => r.name === roleName);
if (role) {
await kcAdminClient.users.addRealmRoleMappings({
id: userId,
roles: [role],
});
}
}
// Remove old roles
if (rolesToRemove && rolesToRemove.length > 0) {
await kcAdminClient.users.delRealmRoleMappings({
id: userId,
roles: rolesToRemove,
});
}
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error updating roles:", error);
return NextResponse.json(

View File

@ -1,7 +1,7 @@
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { redirect } from "next/navigation";
import { ResponsiveIframe } from "@/app/components/responsive-iframe";
import ResponsiveIframe from "@/app/components/responsive-iframe";
export default async function Page() {
const session = await getServerSession(authOptions);
@ -15,7 +15,7 @@ export default async function Page() {
<div className="w-full h-full px-4 pt-12 pb-4">
<ResponsiveIframe
src={process.env.NEXT_PUBLIC_IFRAME_LEARN_URL || ''}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen={true}
/>
</div>
</main>

View File

@ -1,7 +1,7 @@
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { redirect } from "next/navigation";
import { ResponsiveIframe } from "@/app/components/responsive-iframe";
import ResponsiveIframe from "@/app/components/responsive-iframe";
export default async function ArtlabPage() {
const session = await getServerSession(authOptions);
@ -10,12 +10,15 @@ export default async function ArtlabPage() {
redirect("/signin");
}
// Get the design URL from environment variable - intentionally shares URL with design section
const designIframeUrl = process.env.NEXT_PUBLIC_IFRAME_DESIGN_URL || '';
return (
<main className="w-full h-screen bg-black">
<div className="w-full h-full px-4 pt-12 pb-4">
<ResponsiveIframe
src={process.env.NEXT_PUBLIC_IFRAME_ARTLAB_URL || ''}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
src={designIframeUrl}
allowFullScreen={true}
/>
</div>
</main>

View File

@ -1,7 +1,7 @@
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { redirect } from "next/navigation";
import { ResponsiveIframe } from "@/app/components/responsive-iframe";
import ResponsiveIframe from "@/app/components/responsive-iframe";
export default async function Page() {
const session = await getServerSession(authOptions);
@ -15,7 +15,7 @@ export default async function Page() {
<div className="w-full h-full px-4 pt-12 pb-4">
<ResponsiveIframe
src={process.env.NEXT_PUBLIC_IFRAME_CALCULATION_URL || ''}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen={true}
/>
</div>
</main>

View File

@ -1,7 +1,7 @@
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { redirect } from "next/navigation";
import { ResponsiveIframe } from "@/app/components/responsive-iframe";
import ResponsiveIframe from "@/app/components/responsive-iframe";
export default async function ChapitrePage() {
const session = await getServerSession(authOptions);
@ -15,7 +15,7 @@ export default async function ChapitrePage() {
<div className="w-full h-full px-4 pt-12 pb-4">
<ResponsiveIframe
src={process.env.NEXT_PUBLIC_IFRAME_CHAPTER_URL || ''}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen={true}
/>
</div>
</main>

View File

@ -0,0 +1,114 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui/use-toast';
import { AlertCircle, Bug, RefreshCw } from 'lucide-react';
export function EmailDebug() {
const [loading, setLoading] = useState(false);
const handleDebug = async () => {
try {
setLoading(true);
const response = await fetch('/api/courrier/debug-account');
const data = await response.json();
console.log('Account Debug Data:', data);
// Show toast with basic info
toast({
title: "Debug Information",
description: `Found ${data.database.accountCount || 0} accounts and ${data.redis.session?.folderCount || 0} folders`,
duration: 5000
});
} catch (error) {
console.error('Debug error:', error);
toast({
title: "Debug Error",
description: error instanceof Error ? error.message : 'Unknown error',
variant: "destructive",
duration: 5000
});
} finally {
setLoading(false);
}
};
const handleFixFolders = async () => {
try {
setLoading(true);
toast({
title: "Fixing Folders",
description: "Connecting to IMAP server to refresh folders...",
duration: 5000
});
const response = await fetch('/api/courrier/fix-folders', {
method: 'POST'
});
const data = await response.json();
console.log('Fix Folders Result:', data);
if (data.success) {
toast({
title: "Folders Updated",
description: `Processed ${data.accountsProcessed} accounts and found ${data.foldersFound} folders`,
duration: 5000
});
// Refresh the page to see changes
setTimeout(() => {
window.location.reload();
}, 2000);
} else {
toast({
title: "Failed to Update Folders",
description: data.error || 'Unknown error',
variant: "destructive",
duration: 5000
});
}
} catch (error) {
console.error('Fix folders error:', error);
toast({
title: "Folder Update Error",
description: error instanceof Error ? error.message : 'Unknown error',
variant: "destructive",
duration: 5000
});
} finally {
setLoading(false);
}
};
return (
<div className="fixed bottom-4 right-4 z-50 flex gap-2">
<Button
size="sm"
variant="outline"
className="bg-white text-gray-700 hover:text-gray-900 shadow-md"
onClick={handleDebug}
disabled={loading}
>
<Bug className="h-3.5 w-3.5 mr-1" />
Debug
</Button>
<Button
size="sm"
variant="outline"
className="bg-white text-gray-700 hover:text-gray-900 shadow-md"
onClick={handleFixFolders}
disabled={loading}
>
{loading ? (
<RefreshCw className="h-3.5 w-3.5 mr-1 animate-spin" />
) : (
<AlertCircle className="h-3.5 w-3.5 mr-1" />
)}
Fix Folders
</Button>
</div>
);
}

View File

@ -0,0 +1,52 @@
'use client';
import { useEffect, useState } from 'react';
import { toast } from '@/components/ui/use-toast';
export function RedisCacheStatus() {
const [status, setStatus] = useState<'loading' | 'connected' | 'error'>('loading');
useEffect(() => {
// Don't run in production
if (process.env.NODE_ENV === 'production') {
return;
}
const checkRedisStatus = async () => {
try {
const response = await fetch('/api/redis/status');
const data = await response.json();
if (data.ready) {
setStatus('connected');
// No need to dynamically import EmailDebug - it's managed by the debug-tool component
} else {
setStatus('error');
toast({
title: "Redis Connection Issue",
description: "Redis cache is not responding. Email data may be slow to load.",
variant: "destructive",
duration: 5000
});
}
} catch (error) {
setStatus('error');
}
};
checkRedisStatus();
}, []);
// In development, render a minimal indicator
if (process.env.NODE_ENV !== 'production' && status !== 'loading') {
return (
<div className="fixed top-0 right-0 m-2 z-50">
<div className={`h-2 w-2 rounded-full ${
status === 'connected' ? 'bg-green-500' : 'bg-red-500'
}`}></div>
</div>
);
}
return null;
}

View File

@ -1,129 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
interface Task {
id: string;
headline: string;
projectName: string;
projectId: number;
status: number;
dueDate: string | null;
milestone: string | null;
details: string | null;
createdOn: string;
editedOn: string | null;
assignedTo: number[];
}
export default function Flow() {
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const getStatusLabel = (status: number): string => {
switch (status) {
case 1:
return 'New';
case 2:
return 'In Progress';
case 3:
return 'Done';
case 4:
return 'In Progress';
case 5:
return 'Done';
default:
return 'Unknown';
}
};
const getStatusColor = (status: number): string => {
switch (status) {
case 1:
return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300';
case 2:
case 4:
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300';
case 3:
case 5:
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300';
default:
return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300';
}
};
useEffect(() => {
const fetchTasks = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch('/api/leantime/tasks');
if (!response.ok) {
throw new Error('Failed to fetch tasks');
}
const data = await response.json();
if (data.tasks && Array.isArray(data.tasks)) {
// Sort tasks by creation date (oldest first)
const sortedTasks = data.tasks.sort((a: Task, b: Task) => {
const dateA = new Date(a.createdOn).getTime();
const dateB = new Date(b.createdOn).getTime();
return dateA - dateB;
});
setTasks(sortedTasks);
} else {
console.error('Invalid tasks data format:', data);
setError('Invalid tasks data format');
}
} catch (err) {
console.error('Error fetching tasks:', err);
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setLoading(false);
}
};
fetchTasks();
}, []);
if (loading) {
return <div>Loading...</div>;
}
if (error) {
return <div>Error: {error}</div>;
}
if (tasks.length === 0) {
return <div>No tasks found</div>;
}
return (
<div className="grid grid-cols-1 gap-4">
{tasks.map((task) => (
<div
key={task.id}
className="bg-white dark:bg-gray-800 rounded-lg shadow p-4"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
{task.headline}
</h3>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{task.projectName}
</p>
</div>
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(
task.status
)}`}
>
{getStatusLabel(task.status)}
</span>
</div>
</div>
))}
</div>
);
}

View File

@ -1,73 +1,214 @@
'use client';
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useSession } from 'next-auth/react';
interface ResponsiveIframeProps {
export interface ResponsiveIframeProps {
src: string;
title?: string;
token?: string;
className?: string;
allow?: string;
style?: React.CSSProperties;
hideUntilLoad?: boolean;
allowFullScreen?: boolean;
scrolling?: boolean;
heightOffset?: number;
}
export function ResponsiveIframe({ src, className = '', allow, style }: ResponsiveIframeProps) {
// Map of service prefixes to their base URLs - keep in sync with proxy route.ts
const SERVICE_URLS: Record<string, string> = {
'parole': process.env.NEXT_PUBLIC_IFRAME_PAROLE_URL || '',
'alma': process.env.NEXT_PUBLIC_IFRAME_AI_ASSISTANT_URL || '',
'crm': process.env.NEXT_PUBLIC_IFRAME_MEDIATIONS_URL || '',
'vision': process.env.NEXT_PUBLIC_IFRAME_CONFERENCE_URL || '',
'showcase': process.env.NEXT_PUBLIC_IFRAME_SHOWCASE_URL || '',
'agilite': process.env.NEXT_PUBLIC_IFRAME_AGILITY_URL || '',
'dossiers': process.env.NEXT_PUBLIC_IFRAME_DRIVE_URL || '',
'the-message': process.env.NEXT_PUBLIC_IFRAME_THEMESSAGE_URL || '',
'qg': process.env.NEXT_PUBLIC_IFRAME_MISSIONVIEW_URL || '',
'design': process.env.NEXT_PUBLIC_IFRAME_DESIGN_URL || '',
};
export default function ResponsiveIframe({
src,
title = 'Embedded content',
token,
className = '',
style = {},
hideUntilLoad = false,
allowFullScreen = false,
scrolling = true,
heightOffset = 0,
}: ResponsiveIframeProps) {
const [height, setHeight] = useState<number>(0);
const [loaded, setLoaded] = useState<boolean>(false);
const [authError, setAuthError] = useState<boolean>(false);
const iframeRef = useRef<HTMLIFrameElement>(null);
const silentAuthRef = useRef<HTMLIFrameElement>(null);
const silentAuthTimerRef = useRef<NodeJS.Timeout | null>(null);
const { data: session } = useSession();
// Convert proxy URLs to direct URLs to avoid double proxying
const processedSrc = (() => {
if (src.startsWith('/api/proxy/')) {
// Extract the service name and path
const parts = src.replace('/api/proxy/', '').split('/');
const serviceName = parts[0];
const path = parts.slice(1).join('/');
// Look up the base URL for this service
const baseUrl = SERVICE_URLS[serviceName];
if (baseUrl) {
console.log(`Converting proxy URL to direct URL: ${src} -> ${baseUrl}/${path}`);
return `${baseUrl}/${path}`;
}
}
return src;
})();
// Append token to src if provided
const fullSrc = token ? `${processedSrc}${processedSrc.includes('?') ? '&' : '?'}token=${token}` : processedSrc;
// Handle silent authentication refresh
useEffect(() => {
const iframe = iframeRef.current;
if (!iframe) return;
// Setup silent authentication check every 15 minutes
const setupSilentAuth = () => {
if (silentAuthTimerRef.current) {
clearTimeout(silentAuthTimerRef.current);
}
// Create a hidden iframe to check authentication status
silentAuthTimerRef.current = setTimeout(() => {
console.log('Running silent authentication check');
// Create the silent auth iframe if it doesn't exist
if (silentAuthRef.current && !silentAuthRef.current.src) {
silentAuthRef.current.src = '/silent-refresh';
}
// Setup next check
setupSilentAuth();
}, 15 * 60 * 1000); // 15 minutes
};
// Handle messages from the silent auth iframe
const handleAuthMessage = (event: MessageEvent) => {
if (event.data && event.data.type === 'AUTH_STATUS') {
console.log('Received auth status:', event.data);
if (event.data.status === 'UNAUTHENTICATED') {
console.error('Silent authentication failed - user is not authenticated');
setAuthError(true);
// Force immediate refresh
if (silentAuthRef.current) {
silentAuthRef.current.src = '/silent-refresh';
}
} else if (event.data.status === 'AUTHENTICATED') {
console.log('Silent authentication successful');
setAuthError(false);
}
}
};
window.addEventListener('message', handleAuthMessage);
setupSilentAuth();
// Cleanup
return () => {
window.removeEventListener('message', handleAuthMessage);
if (silentAuthTimerRef.current) {
clearTimeout(silentAuthTimerRef.current);
}
};
}, []);
const calculateHeight = () => {
const pageY = (elem: HTMLElement): number => {
return elem.offsetParent ?
(elem.offsetTop + pageY(elem.offsetParent as HTMLElement)) :
elem.offsetTop;
};
const height = document.documentElement.clientHeight;
const iframeY = pageY(iframe);
const newHeight = Math.max(0, height - iframeY);
iframe.style.height = `${newHeight}px`;
// Adjust iframe height based on window size
useEffect(() => {
const updateHeight = () => {
const viewportHeight = window.innerHeight;
const newHeight = viewportHeight - heightOffset;
setHeight(newHeight > 0 ? newHeight : 0);
};
updateHeight();
window.addEventListener('resize', updateHeight);
return () => {
window.removeEventListener('resize', updateHeight);
};
}, [heightOffset]);
// Handle hash changes by updating iframe source
useEffect(() => {
const handleHashChange = () => {
if (window.location.hash && window.location.hash.length) {
const iframeURL = new URL(iframe.src);
iframeURL.hash = window.location.hash;
iframe.src = iframeURL.toString();
const iframe = iframeRef.current;
if (iframe && iframe.src) {
const url = new URL(iframe.src);
// If there's a hash in the parent window's URL
if (window.location.hash) {
url.hash = window.location.hash;
iframe.src = url.toString();
}
}
};
// Initial setup
calculateHeight();
handleHashChange();
// Event listeners
window.addEventListener('resize', calculateHeight);
window.addEventListener('hashchange', handleHashChange);
iframe.addEventListener('load', calculateHeight);
// Cleanup
return () => {
window.removeEventListener('resize', calculateHeight);
window.removeEventListener('hashchange', handleHashChange);
iframe.removeEventListener('load', calculateHeight);
};
}, []);
return (
<iframe
ref={iframeRef}
id="myFrame"
src={src}
className={`w-full border-none ${className}`}
style={{
display: 'block',
width: '100%',
height: '100%',
...style
}}
allow={allow}
allowFullScreen
/>
<>
{/* Hidden iframe for silent authentication */}
<iframe
ref={silentAuthRef}
style={{ display: 'none' }}
title="Silent Authentication"
/>
{/* Main content iframe */}
<div className="relative w-full h-full">
{authError && (
<div className="absolute inset-0 flex items-center justify-center bg-red-50 bg-opacity-90 z-10">
<div className="text-center p-4">
<p className="text-red-600 font-semibold">Session expired or authentication error</p>
<button
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
onClick={() => window.location.href = '/api/auth/signin?callbackUrl=' + encodeURIComponent(window.location.href)}
>
Sign in again
</button>
</div>
</div>
)}
<iframe
ref={iframeRef}
src={fullSrc}
title={title}
className={`w-full ${className}`}
style={{
height: height > 0 ? `${height}px` : '100%',
border: 'none',
visibility: hideUntilLoad && !loaded ? 'hidden' : 'visible',
...style,
}}
onLoad={() => {
setLoaded(true);
}}
allowFullScreen={allowFullScreen}
scrolling={scrolling ? 'yes' : 'no'}
/>
{hideUntilLoad && !loaded && (
<div className="flex justify-center items-center w-full h-full absolute top-0 left-0">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500"></div>
</div>
)}
</div>
</>
);
}

View File

@ -0,0 +1,82 @@
'use client';
import { useEffect, useState } from 'react';
import { useSession } from 'next-auth/react';
export function RocketChatAuth() {
const { data: session } = useSession();
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function authenticateWithRocketChat() {
if (!session?.user?.email) {
setError('No user session available');
return;
}
try {
console.log('Authenticating with Rocket.Chat for user:', session.user.email);
// Call our API to get Rocket.Chat tokens
const response = await fetch('/api/auth/rocket-login');
// Get the text response for debugging if there's an error
const responseText = await response.text();
if (!response.ok) {
console.error('Failed to authenticate with Rocket.Chat:', responseText);
setError(`Failed to authenticate: ${response.status} ${response.statusText}`);
return;
}
// Parse the JSON now that we've read the text
const data = JSON.parse(responseText);
if (data.rocketChatToken && data.rocketChatUserId) {
console.log('Received tokens from API:', {
hasToken: !!data.rocketChatToken,
hasUserId: !!data.rocketChatUserId
});
// Get the current hostname to set domain cookies properly
const hostname = window.location.hostname;
const domain = hostname.includes('localhost') ? 'localhost' : hostname;
console.log(`Setting cookies for domain: ${domain}`);
// Store tokens in cookies that can be accessed by the Rocket.Chat iframe
document.cookie = `rc_token=${data.rocketChatToken}; path=/; domain=${domain}; SameSite=None; Secure`;
document.cookie = `rc_uid=${data.rocketChatUserId}; path=/; domain=${domain}; SameSite=None; Secure`;
// Also store in localStorage which Rocket.Chat might check
localStorage.setItem('Meteor.loginToken', data.rocketChatToken);
localStorage.setItem('Meteor.userId', data.rocketChatUserId);
console.log('Successfully authenticated with Rocket.Chat');
setIsAuthenticated(true);
setError(null);
} else {
setError('Received invalid response from authentication API');
console.error('Invalid response data:', data);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.error('Error authenticating with Rocket.Chat:', errorMessage);
setError(`Error: ${errorMessage}`);
}
}
authenticateWithRocketChat();
}, [session]);
// This component doesn't render visible UI by default
return error ? (
<div className="fixed bottom-4 right-4 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded z-50" role="alert">
<strong className="font-bold">Authentication Error: </strong>
<span className="block sm:inline">{error}</span>
</div>
) : null;
}
export default RocketChatAuth;

View File

@ -0,0 +1,67 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
interface Account {
id: number | string;
name: string;
email: string;
color: string;
folders?: string[];
}
interface DebugViewProps {
accounts: Account[];
selectedAccount: Account | null;
mailboxes: string[];
}
export default function DebugView({ accounts, selectedAccount, mailboxes }: DebugViewProps) {
const [isOpen, setIsOpen] = useState(false);
if (!isOpen) {
return (
<Button
className="fixed bottom-4 right-4 bg-red-600 hover:bg-red-700 text-white z-50"
onClick={() => setIsOpen(true)}
>
Debug
</Button>
);
}
return (
<div className="fixed inset-0 bg-black/80 z-50 overflow-auto flex flex-col p-4">
<div className="bg-white rounded-lg p-4 mb-4 max-w-3xl mx-auto w-full">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold">Debug View</h2>
<Button variant="outline" onClick={() => setIsOpen(false)}>Close</Button>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<h3 className="text-lg font-semibold mb-2">Accounts ({accounts.length})</h3>
<pre className="bg-gray-100 p-2 rounded text-xs overflow-auto max-h-[300px]">
{JSON.stringify(accounts, null, 2)}
</pre>
</div>
<div>
<h3 className="text-lg font-semibold mb-2">Selected Account</h3>
<pre className="bg-gray-100 p-2 rounded text-xs overflow-auto max-h-[300px]">
{selectedAccount ? JSON.stringify(selectedAccount, null, 2) : 'None'}
</pre>
</div>
</div>
<div className="mt-4">
<h3 className="text-lg font-semibold mb-2">Mailboxes ({mailboxes?.length || 0})</h3>
<pre className="bg-gray-100 p-2 rounded text-xs overflow-auto max-h-[150px]">
{JSON.stringify(mailboxes, null, 2)}
</pre>
</div>
</div>
</div>
);
}

11
app/courrier/layout.tsx Normal file
View File

@ -0,0 +1,11 @@
export default function CourrierLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
{children}
</>
);
}

View File

@ -1,113 +0,0 @@
/**
* This is a debugging component that provides troubleshooting tools
* for the email loading process in the Courrier application.
*/
'use client';
import { Button } from '@/components/ui/button';
import { RefreshCw, AlertCircle, CheckCircle } from 'lucide-react';
interface LoadingFixProps {
loading: boolean;
isLoadingInitial: boolean;
setLoading: (value: boolean) => void;
setIsLoadingInitial: (value: boolean) => void;
setEmails: (emails: any[]) => void;
loadEmails: () => void;
emails: any[];
}
export function LoadingFix({
loading,
isLoadingInitial,
setLoading,
setIsLoadingInitial,
setEmails,
loadEmails,
emails
}: LoadingFixProps) {
const forceResetLoadingStates = () => {
console.log('[DEBUG] Force resetting loading states to false');
// Force both loading states to false
setLoading(false);
setIsLoadingInitial(false);
};
const forceTriggerLoad = () => {
console.log('[DEBUG] Force triggering a new email load');
setLoading(true);
setTimeout(() => {
loadEmails();
}, 100);
};
const resetEmailState = () => {
console.log('[DEBUG] Resetting email state to empty array');
setEmails([]);
setTimeout(() => {
forceTriggerLoad();
}, 100);
};
return (
<div className="fixed bottom-4 right-4 z-50 p-4 bg-white shadow-lg rounded-lg border border-gray-200">
<div className="text-sm font-medium mb-2">Debug Tools</div>
<div className="flex flex-col gap-2">
<div className="flex items-center text-xs">
<span className="inline-block w-28">Loading State:</span>
{loading ? (
<span className="text-yellow-500 flex items-center">
<AlertCircle className="h-3 w-3 mr-1" /> Active
</span>
) : (
<span className="text-green-500 flex items-center">
<CheckCircle className="h-3 w-3 mr-1" /> Inactive
</span>
)}
</div>
<div className="flex items-center text-xs">
<span className="inline-block w-28">Initial Loading:</span>
{isLoadingInitial ? (
<span className="text-yellow-500 flex items-center">
<AlertCircle className="h-3 w-3 mr-1" /> Active
</span>
) : (
<span className="text-green-500 flex items-center">
<CheckCircle className="h-3 w-3 mr-1" /> Inactive
</span>
)}
</div>
<div className="flex items-center text-xs">
<span className="inline-block w-28">Emails Loaded:</span>
<span className={emails.length > 0 ? "text-green-500" : "text-red-500"}>
{emails.length}
</span>
</div>
<Button
variant="outline"
size="sm"
className="text-xs h-8"
onClick={forceResetLoadingStates}
>
Reset Loading States
</Button>
<Button
variant="outline"
size="sm"
className="text-xs h-8"
onClick={forceTriggerLoad}
>
Force Reload Emails
</Button>
<Button
variant="outline"
size="sm"
className="text-xs h-8"
onClick={resetEmailState}
>
Reset Email State
</Button>
</div>
</div>
);
}

View File

@ -1,116 +0,0 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
export default function MailLoginPage() {
const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [host, setHost] = useState('mail.infomaniak.com');
const [port, setPort] = useState('993');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const response = await fetch('/api/courrier/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email,
password,
host,
port,
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to connect to email server');
}
// Redirect to mail page
router.push('/mail');
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Email Configuration</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div>
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<div>
<Label htmlFor="host">IMAP Host</Label>
<Input
id="host"
type="text"
value={host}
onChange={(e) => setHost(e.target.value)}
required
/>
</div>
<div>
<Label htmlFor="port">IMAP Port</Label>
<Input
id="port"
type="text"
value={port}
onChange={(e) => setPort(e.target.value)}
required
/>
</div>
{error && (
<div className="text-red-500 text-sm">{error}</div>
)}
<Button
type="submit"
className="w-full"
disabled={loading}
>
{loading ? 'Connecting...' : 'Connect'}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { redirect } from "next/navigation";
import { ResponsiveIframe } from "@/app/components/responsive-iframe";
import ResponsiveIframe from "@/app/components/responsive-iframe";
export default async function Page() {
const session = await getServerSession(authOptions);
@ -15,7 +15,7 @@ export default async function Page() {
<div className="w-full h-full px-4 pt-12 pb-4">
<ResponsiveIframe
src={process.env.NEXT_PUBLIC_IFRAME_MEDIATIONS_URL || ''}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen={true}
/>
</div>
</main>

View File

@ -1,7 +1,7 @@
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { redirect } from "next/navigation";
import { ResponsiveIframe } from "@/app/components/responsive-iframe";
import ResponsiveIframe from "@/app/components/responsive-iframe";
export default async function Page() {
const session = await getServerSession(authOptions);
@ -15,12 +15,12 @@ export default async function Page() {
<div className="w-full h-full px-4 pt-12 pb-4">
<ResponsiveIframe
src={process.env.NEXT_PUBLIC_IFRAME_DRIVE_URL || ''}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
className="relative"
style={{
marginTop: '-50px', // Adjust this value based on the Nextcloud navbar height
height: 'calc(100% + 50px)' // Compensate for the negative margin
}}
allowFullScreen={true}
/>
</div>
</main>

View File

@ -1,7 +1,7 @@
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { redirect } from "next/navigation";
import { ResponsiveIframe } from "@/app/components/responsive-iframe";
import ResponsiveIframe from "@/app/components/responsive-iframe";
export default async function Page() {
const session = await getServerSession(authOptions);
@ -15,7 +15,7 @@ export default async function Page() {
<div className="w-full h-full px-4 pt-12 pb-4">
<ResponsiveIframe
src={process.env.NEXT_PUBLIC_IFRAME_GITE_URL || ''}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen={true}
/>
</div>
</main>

View File

@ -74,17 +74,241 @@
}
}
/* Email specific styles */
.email-content table { width: 100%; border-collapse: collapse; }
.email-content table.table-container { width: auto; margin-bottom: 20px; }
.email-content td, .email-content th { padding: 8px; border: 1px solid #e5e7eb; }
.email-content img { max-width: 100%; height: auto; }
.email-content div[style] { max-width: 100% !important; }
.email-content * { max-width: 100% !important; word-wrap: break-word; }
.email-content font { font-family: inherit; }
.email-content .total-row td { border-top: 1px solid #e5e7eb; }
.email-content a { color: #3b82f6; text-decoration: underline; }
.email-content p { margin-bottom: 0.75em; }
.email-content .header { margin-bottom: 1em; }
.email-content .footer { font-size: 0.875rem; color: #6b7280; margin-top: 1em; }
/* Email display styles */
.email-content-display {
max-width: 100%;
word-wrap: break-word;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-size: 14px;
}
/* Preserve email structure */
.email-content-display * {
max-width: 100% !important;
}
/* Images */
.email-content-display img {
max-width: 100%;
height: auto;
display: inline-block;
margin: 8px 0;
}
/* Tables */
.email-content-display table {
width: 100% !important;
border-collapse: collapse;
margin: 16px 0;
table-layout: fixed;
border: 1px solid #ddd;
}
/* Table wrapper for overflow handling */
.email-content-display div:has(> table) {
overflow-x: auto;
max-width: 100%;
margin: 16px 0;
}
.email-content-display td,
.email-content-display th {
padding: 8px;
border: 1px solid #ddd;
word-break: break-word;
overflow-wrap: break-word;
max-width: 100%;
font-size: 13px;
}
/* Make sure quoted content tables are properly displayed */
.email-content-display .quoted-content table,
.email-content-display blockquote table {
font-size: 12px;
margin: 8px 0;
width: 100% !important;
border: 1px solid #ddd;
}
.email-content-display .quoted-content td,
.email-content-display .quoted-content th,
.email-content-display blockquote td,
.email-content-display blockquote th {
padding: 6px;
font-size: 12px;
border: 1px solid #ddd;
}
/* Quote blocks for email replies */
.email-content-display blockquote,
.email-content-display .quoted-content {
margin: 16px 0;
padding: 8px 16px;
border-left: 2px solid #ddd;
color: #505050;
background-color: #f9f9f9;
border-radius: 4px;
font-size: 13px;
}
/* Special classes used in the email formatting functions */
.email-content-display .reply-body {
width: 100%;
font-family: Arial, sans-serif;
margin-top: 20px;
}
.email-content-display .quote-header {
color: #555;
font-size: 13px;
margin: 20px 0 10px 0;
font-weight: 400;
}
.email-content-display .quoted-content {
font-size: 13px;
line-height: 1.5;
}
.email-content-display .email-original-content {
margin-top: 10px;
padding-top: 10px;
}
/* Fix styles for the content in both preview and compose */
.email-content-display[contenteditable="false"] {
/* Same styles as contentEditable=true to ensure consistency */
white-space: pre-wrap;
word-break: break-word;
}
/* Quill editor customizations for email composition */
.ql-editor {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-size: 14px;
line-height: 1.5;
padding: 12px;
overflow-y: auto !important;
}
/* Quote formatting for forwarded/replied emails */
.ql-editor blockquote {
border-left: 2px solid #ddd !important;
padding: 10px 0 10px 15px !important;
margin: 8px 0 !important;
color: #505050 !important;
background-color: #f9f9f9 !important;
border-radius: 4px !important;
font-size: 13px !important;
}
/* Table formatting in the editor */
.ql-editor table {
width: 100% !important;
border-collapse: collapse !important;
table-layout: fixed !important;
margin: 10px 0 !important;
border: 1px solid #ddd !important;
}
.ql-editor td,
.ql-editor th {
border: 1px solid #ddd !important;
padding: 6px 8px !important;
overflow-wrap: break-word !important;
word-break: break-word !important;
min-width: 30px !important;
font-size: 13px !important;
}
/* Fix toolbar button styling */
.ql-toolbar.ql-snow {
border-top: none;
border-left: none;
border-right: none;
border-bottom: 1px solid #e5e7eb;
}
.ql-container.ql-snow {
border: none;
}
/* Style for "On [date], [person] wrote:" line */
.ql-editor div[style*="font-weight: 400"] {
margin-top: 20px !important;
margin-bottom: 8px !important;
color: #555 !important;
font-size: 13px !important;
}
/* Support for RTL content */
.email-content-display[dir="rtl"],
.email-content-display [dir="rtl"] {
text-align: right;
}
/* Remove any padding/margins from the first and last elements */
.email-content-display > *:first-child {
margin-top: 0;
}
.email-content-display > *:last-child {
margin-bottom: 0;
}
/* Forwarded message header styling */
.email-content-display div {
color: #555;
}
/* Forwarded message styling */
.email-content-display div[style*="forwarded message"],
.email-content-display div[class*="forwarded-message"],
.email-content-display div[class*="forwarded_message"] {
color: #555;
font-family: Arial, sans-serif;
margin-bottom: 15px;
}
/* Buttons */
.email-content-display button,
.email-content-display a[role="button"],
.email-content-display a.button,
.email-content-display div.button,
.email-content-display [class*="btn"],
.email-content-display [class*="button"] {
display: inline-block;
padding: 8px 16px;
background-color: #f97316;
color: white;
border-radius: 4px;
text-decoration: none;
font-weight: 500;
margin: 8px 0;
text-align: center;
cursor: pointer;
border: none;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
/* Links */
.email-content-display a {
color: #3b82f6;
text-decoration: underline;
}
/* Headers and text */
.email-content-display h1,
.email-content-display h2,
.email-content-display h3 {
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
line-height: 1.25;
}
.email-content-display p {
margin-bottom: 16px;
}

136
app/hooks/use-courrier.ts Normal file
View File

@ -0,0 +1,136 @@
/**
* Change the current folder and load emails from that folder
*/
const changeFolder = async (folder: string, accountId?: string) => {
console.log(`Changing folder to ${folder} for account ${accountId || 'default'}`);
try {
// Reset selected email
setSelectedEmail(null);
setSelectedEmailIds([]);
// Record the new folder
setCurrentFolder(folder);
// Reset search query when changing folders
setSearchQuery('');
// Reset to page 1
setPage(1);
// Clear existing emails before loading new ones to prevent UI flicker
setEmails([]);
// Show loading state
setIsLoading(true);
// Load emails for the new folder with a deliberate delay to allow state to update
await new Promise(resolve => setTimeout(resolve, 100));
await loadEmails(folder, 1, 20, accountId);
} catch (error) {
console.error(`Error changing to folder ${folder}:`, error);
setError(`Failed to load emails from ${folder}: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally {
setIsLoading(false);
}
};
/**
* Load emails for the current folder
*/
const loadEmails = async (
folderOverride?: string,
pageOverride?: number,
perPageOverride?: number,
accountIdOverride?: string
) => {
const folderToUse = folderOverride || currentFolder;
// Enhanced folder and account handling
let normalizedFolder = folderToUse;
let normalizedAccountId = accountIdOverride;
// Extract account ID from folder if it has a prefix and no explicit account ID was provided
if (folderToUse.includes(':')) {
const [folderAccountId, baseFolderName] = folderToUse.split(':');
console.log(`Folder has prefix: accountId=${folderAccountId}, baseName=${baseFolderName}`);
// If no explicit account ID was provided, use the one from the folder name
if (!normalizedAccountId) {
normalizedAccountId = folderAccountId;
console.log(`Using account ID from folder prefix: ${normalizedAccountId}`);
}
// If both exist but don't match, log a warning
else if (normalizedAccountId !== folderAccountId) {
console.warn(`⚠️ Mismatch between folder account prefix (${folderAccountId}) and provided accountId (${normalizedAccountId})`);
console.warn(`Using provided accountId (${normalizedAccountId}) but this may cause unexpected behavior`);
}
}
const pageToUse = pageOverride || page;
const perPageToUse = perPageOverride || perPage;
console.log(`Loading emails: folder=${folderToUse}, page=${pageToUse}, accountId=${normalizedAccountId || 'default'}`);
try {
setIsLoading(true);
setError('');
// Construct the API URL with a unique timestamp to prevent caching
let url = `/api/courrier/emails?folder=${encodeURIComponent(folderToUse)}&page=${pageToUse}&perPage=${perPageToUse}`;
// Add accountId parameter if specified
if (normalizedAccountId) {
url += `&accountId=${encodeURIComponent(normalizedAccountId)}`;
}
// Add cache-busting timestamp
url += `&_t=${Date.now()}`;
console.log(`Fetching emails from API: ${url}`);
const response = await fetch(url);
if (!response.ok) {
let errorText;
try {
const errorData = await response.json();
errorText = errorData.error || `Server error: ${response.status}`;
} catch {
errorText = `HTTP error: ${response.status}`;
}
console.error(`API error: ${errorText}`);
throw new Error(errorText);
}
const data = await response.json();
console.log(`Received ${data.emails?.length || 0} emails from API`);
if (pageToUse === 1 || !pageOverride) {
// Replace emails when loading first page
console.log(`Setting ${data.emails?.length || 0} emails (replacing existing)`);
setEmails(data.emails || []);
} else {
// Append emails when loading subsequent pages
console.log(`Appending ${data.emails?.length || 0} emails to existing list`);
setEmails(prev => [...prev, ...(data.emails || [])]);
}
// Update pagination info
setTotalPages(data.totalPages || 0);
if (data.mailboxes && data.mailboxes.length > 0) {
console.log(`Received ${data.mailboxes.length} mailboxes from API`);
setMailboxes(data.mailboxes);
}
return data;
} catch (error) {
console.error('Error loading emails:', error);
setError(`Failed to load emails: ${error instanceof Error ? error.message : 'Unknown error'}`);
// Set empty emails array on error to prevent UI issues
if (pageToUse === 1) {
setEmails([]);
}
return null;
} finally {
setIsLoading(false);
}
};

View File

@ -6,26 +6,44 @@ import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { Providers } from "@/components/providers";
import { LayoutWrapper } from "@/components/layout/layout-wrapper";
import { warmupRedisCache } from '@/lib/redis';
const inter = Inter({ subsets: ["latin"] });
// Warm up Redis connection during app initialization
warmupRedisCache().catch(console.error);
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await getServerSession(authOptions);
// Try to get the session, but handle potential errors gracefully
let session = null;
let sessionError = false;
try {
session = await getServerSession(authOptions);
} catch (error) {
console.error("Error getting server session:", error);
sessionError = true;
}
const headersList = await headers();
const pathname = headersList.get("x-pathname") || "";
const isSignInPage = pathname === "/signin";
// If we're on the signin page and there was a session error,
// don't pass the session to avoid refresh attempts
const safeSession = isSignInPage && sessionError ? null : session;
return (
<html lang="fr">
<body className={inter.className}>
<Providers>
<Providers session={safeSession}>
<LayoutWrapper
isSignInPage={isSignInPage}
isAuthenticated={!!session}
isAuthenticated={!!session && !sessionError}
>
{children}
</LayoutWrapper>

7
app/loggedout/layout.tsx Normal file
View File

@ -0,0 +1,7 @@
export default function LoggedOutLayout({
children,
}: {
children: React.ReactNode;
}) {
return children;
}

282
app/loggedout/page.tsx Normal file
View File

@ -0,0 +1,282 @@
"use client";
import { useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import Link from 'next/link';
export default function LoggedOutPage() {
const [sessionStatus, setSessionStatus] = useState<'checking' | 'cleared' | 'error'>('checking');
const [message, setMessage] = useState<string>('');
const [keycloakLogoutUrl, setKeycloakLogoutUrl] = useState<string | null>(null);
const iframeRef = useRef<HTMLIFrameElement>(null);
const searchParams = useSearchParams();
const preserveSso = searchParams.get('preserveSso') === 'true';
useEffect(() => {
// Listen for messages from iframes
const handleMessage = (event: MessageEvent) => {
if (event.data && event.data.type === 'AUTH_STATUS') {
console.log('Received auth status message:', event.data);
}
};
window.addEventListener('message', handleMessage);
const clearSessions = async () => {
try {
console.log(`Clearing sessions (preserveSso: ${preserveSso})`);
// Try to get any stored user IDs for server-side cleanup
let userId = null;
try {
// Check localStorage first
userId = localStorage.getItem('userId') || sessionStorage.getItem('userId');
// Try to get from sessionStorage as fallback
if (!userId) {
const sessionData = sessionStorage.getItem('nextauth.session-token');
if (sessionData) {
try {
const parsed = JSON.parse(atob(sessionData.split('.')[1]));
userId = parsed.sub || parsed.id;
} catch (e) {
console.error('Failed to parse session data:', e);
}
}
}
} catch (e) {
console.error('Error accessing storage:', e);
}
// If we found a user ID, call server-side cleanup
if (userId) {
console.log(`Found user ID: ${userId}, cleaning up server-side`);
try {
const response = await fetch('/api/auth/session-cleanup', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
userId,
preserveSso
}),
credentials: 'include'
});
const result = await response.json();
console.log('Server cleanup result:', result);
} catch (e) {
console.error('Error during server-side cleanup:', e);
}
}
// If not preserving SSO, get Keycloak logout URL
if (!preserveSso) {
try {
const response = await fetch('/api/auth/full-logout', {
credentials: 'include'
});
const data = await response.json();
if (data.success && data.logoutUrl) {
setKeycloakLogoutUrl(data.logoutUrl);
console.log('Keycloak logout URL:', data.logoutUrl);
}
} catch (e) {
console.error('Failed to get Keycloak logout URL:', e);
}
}
// Clear cookies appropriately based on preserve SSO setting
const cookies = document.cookie.split(';');
// Get all cookies names
const cookieNames = cookies.map(cookie => cookie.split('=')[0].trim());
// Find chunked cookies
const chunkedCookies = cookieNames
.filter(name => /next-auth.*\.\d+$/.test(name));
// Define cookies to clear
let cookiesToClear = [];
if (preserveSso) {
// Only clear app-specific cookies if preserving SSO
cookiesToClear = [
'next-auth.session-token',
'next-auth.csrf-token',
'next-auth.callback-url',
'__Secure-next-auth.session-token',
'__Host-next-auth.csrf-token',
...chunkedCookies
];
} else {
// Clear ALL auth-related cookies for full logout
const authCookies = cookieNames.filter(name =>
name.includes('auth') ||
name.includes('KEYCLOAK') ||
name.includes('KC_') ||
name.includes('session')
);
cookiesToClear = [
...authCookies,
...chunkedCookies,
'JSESSIONID',
'KEYCLOAK_SESSION',
'KEYCLOAK_IDENTITY',
'KC_RESTART'
];
}
// Clear the cookies with all possible path and domain combinations
const hostname = window.location.hostname;
const baseDomain = hostname.split('.').slice(-2).join('.');
cookiesToClear.forEach(cookieName => {
// Try various path and domain combinations to ensure complete cleanup
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=${hostname};`;
// Only try this for multi-part domains
if (hostname !== baseDomain) {
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=.${baseDomain};`;
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=.${hostname};`;
}
// Add secure and SameSite attributes
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; SameSite=None; Secure;`;
});
// Clear session storage
try {
sessionStorage.clear();
} catch (e) {
console.error('Failed to clear sessionStorage:', e);
}
// Clear auth-related localStorage items
try {
localStorage.removeItem('userId');
localStorage.removeItem('userName');
localStorage.removeItem('userEmail');
localStorage.removeItem('token');
localStorage.removeItem('refreshToken');
// Rocket Chat items
localStorage.removeItem('Meteor.loginToken');
localStorage.removeItem('Meteor.userId');
} catch (e) {
console.error('Failed to clear localStorage items:', e);
}
// Notify parent window if we're in an iframe
if (window !== window.parent) {
try {
window.parent.postMessage({
type: 'AUTH_STATUS',
status: 'LOGGED_OUT',
preserveSso
}, '*');
} catch (e) {
console.error('Failed to send logout message to parent:', e);
}
}
setSessionStatus('cleared');
setMessage(preserveSso
? 'You have been logged out of this application, but your SSO session is still active for other services.'
: 'You have been completely logged out of all services.'
);
} catch (error) {
console.error('Error during logout cleanup:', error);
setSessionStatus('error');
setMessage('There was an error during the logout process.');
}
};
clearSessions();
return () => {
window.removeEventListener('message', handleMessage);
};
}, [preserveSso]);
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 p-4">
<div className="w-full max-w-md p-8 space-y-6 bg-white rounded-lg shadow-md">
<div className="text-center">
<h1 className="text-2xl font-bold text-gray-900 mb-2">
{preserveSso ? 'Application Sign Out' : 'Complete Sign Out'}
</h1>
{sessionStatus === 'checking' && (
<p className="text-gray-600">Cleaning up your session...</p>
)}
{sessionStatus === 'cleared' && (
<>
<p className="text-gray-600 mb-4">{message}</p>
{preserveSso ? (
<>
<p className="text-sm text-gray-500 mb-4">
You can continue using other applications without signing in again.
</p>
<div className="flex flex-col space-y-4">
<Link
href="/"
className="inline-block px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
>
Return to Home Page
</Link>
<button
onClick={() => window.location.href = '/loggedout'}
className="inline-block px-4 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300 transition-colors"
>
Sign Out Completely
</button>
</div>
</>
) : (
<>
<p className="text-sm text-gray-500 mb-4">
You will need to sign in again to access any protected services.
</p>
{keycloakLogoutUrl && (
<iframe
ref={iframeRef}
src={keycloakLogoutUrl}
style={{ width: '1px', height: '1px', position: 'absolute', top: '-100px', left: '-100px' }}
title="Keycloak Logout"
/>
)}
<Link
href="/api/auth/signin?fresh=true"
className="inline-block px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
>
Sign In Again
</Link>
</>
)}
</>
)}
{sessionStatus === 'error' && (
<>
<p className="text-red-600 mb-4">{message}</p>
<Link
href="/"
className="inline-block px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
>
Return to Home Page
</Link>
</>
)}
</div>
</div>
</div>
);
}

View File

@ -15,7 +15,7 @@ export default async function Page() {
<iframe
src={process.env.NEXT_PUBLIC_IFRAME_MEDIATIONS_URL}
className="w-full h-full border-none"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen={true}
style={{
marginTop: '-64px'
}}

103
app/ms/page.tsx Normal file
View File

@ -0,0 +1,103 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
export default function MicrosoftCallbackPage() {
const router = useRouter();
const searchParams = useSearchParams();
const [status, setStatus] = useState<string>('Processing authentication...');
const [error, setError] = useState<string | null>(null);
const [errorDetails, setErrorDetails] = useState<string | null>(null);
useEffect(() => {
async function handleCallback() {
try {
// Extract code and state from URL
const code = searchParams.get('code');
const state = searchParams.get('state');
const errorMsg = searchParams.get('error');
const errorDescription = searchParams.get('error_description');
if (errorMsg) {
setError(`Authentication error: ${errorMsg}`);
if (errorDescription) {
// URL decode the error description
const decodedErrorDescription = decodeURIComponent(errorDescription);
setErrorDetails(decodedErrorDescription);
console.error('Auth error details:', decodedErrorDescription);
}
return;
}
if (!code || !state) {
setError('Missing required parameters');
return;
}
setStatus('Authentication successful. Exchanging code for tokens...');
// Send code to our API to exchange for tokens
const response = await fetch('/api/courrier/microsoft/callback', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ code, state }),
});
const data = await response.json();
if (!response.ok) {
setError(data.error || 'Failed to process authentication');
if (data.details) {
setErrorDetails(data.details);
}
return;
}
setStatus('Account connected successfully!');
// Redirect back to email client after a short delay
setTimeout(() => {
router.push('/courrier');
}, 2000);
} catch (err) {
setError('An unexpected error occurred');
console.error('Callback processing error:', err);
}
}
handleCallback();
}, [router, searchParams]);
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50">
<div className="w-full max-w-md p-8 space-y-4 bg-white rounded-xl shadow-md">
<h1 className="text-2xl font-bold text-center">Microsoft Account Connection</h1>
{error ? (
<div className="p-4 bg-red-50 text-red-700 rounded-md">
<p className="font-medium">Error</p>
<p>{error}</p>
{errorDetails && (
<div className="mt-2 p-2 bg-red-100 rounded text-sm overflow-auto max-h-40">
<p>{errorDetails}</p>
</div>
)}
<button
onClick={() => router.push('/courrier')}
className="mt-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
Return to Email Client
</button>
</div>
) : (
<div className="p-4 bg-blue-50 text-blue-700 rounded-md">
<p>{status}</p>
</div>
)}
</div>
</div>
);
}

View File

@ -4,7 +4,7 @@ export default function NotificationsPage() {
<iframe
src="https://example.com/notifications"
className="w-full h-full border-none"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen={true}
allowFullScreen
/>
</div>

View File

@ -36,7 +36,7 @@ export default function Home() {
<QuoteCard />
</div>
<div className="col-span-3">
<Calendar />
<Calendar limit={5} showMore={true} showRefresh={true} />
</div>
<div className="col-span-3">
<News />

View File

@ -1,7 +1,67 @@
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { redirect } from "next/navigation";
import { ResponsiveIframe } from "@/app/components/responsive-iframe";
import ResponsiveIframe from "@/app/components/responsive-iframe";
import RocketChatAuth from "@/app/components/rocket-auth";
// Function to get Rocket.Chat token for server-side authentication
async function getRocketChatTokensServer(email: string) {
try {
const baseUrl = process.env.NEXT_PUBLIC_IFRAME_PAROLE_URL?.split('/channel')[0];
if (!baseUrl) return null;
// Admin headers for Rocket.Chat API
const adminHeaders = {
'X-Auth-Token': process.env.ROCKET_CHAT_TOKEN!,
'X-User-Id': process.env.ROCKET_CHAT_USER_ID!,
'Content-Type': 'application/json'
};
// Get the username from email
const username = email.split('@')[0];
// Get all users to find the current user
const usersResponse = await fetch(`${baseUrl}/api/v1/users.list`, {
method: 'GET',
headers: adminHeaders,
cache: 'no-store' // Don't cache this request
});
if (!usersResponse.ok) return null;
const usersData = await usersResponse.json();
// Find the current user in the list
const currentUser = usersData.users.find((user: any) =>
user.username === username ||
(user.emails && user.emails.some((emailObj: any) => emailObj.address === email))
);
if (!currentUser) return null;
// Create a token for the current user
const createTokenResponse = await fetch(`${baseUrl}/api/v1/users.createToken`, {
method: 'POST',
headers: adminHeaders,
body: JSON.stringify({
userId: currentUser._id
}),
cache: 'no-store' // Don't cache this request
});
if (!createTokenResponse.ok) return null;
const tokenData = await createTokenResponse.json();
return {
token: tokenData.data.authToken,
userId: currentUser._id
};
} catch (error) {
console.error('Error getting server-side Rocket.Chat token:', error);
return null;
}
}
export default async function Page() {
const session = await getServerSession(authOptions);
@ -10,12 +70,33 @@ export default async function Page() {
redirect("/signin");
}
// Try to get Rocket.Chat tokens server-side
let rocketChatUrl = process.env.NEXT_PUBLIC_IFRAME_PAROLE_URL || '';
if (session.user?.email) {
const rocketTokens = await getRocketChatTokensServer(session.user.email);
if (rocketTokens) {
// Add token to URL for direct authentication
// Note: This is only for development/testing - in production,
// consider more secure methods
const urlObj = new URL(rocketChatUrl);
urlObj.searchParams.set('resumeToken', rocketTokens.token);
urlObj.searchParams.set('rc_uid', rocketTokens.userId);
urlObj.searchParams.set('rc_token', rocketTokens.token);
rocketChatUrl = urlObj.toString();
}
}
return (
<main className="w-full h-screen bg-black">
{/* Keep RocketChatAuth for client-side backup authentication */}
<RocketChatAuth />
<div className="w-full h-full px-4 pt-12 pb-4">
<ResponsiveIframe
src={process.env.NEXT_PUBLIC_IFRAME_PAROLE_URL || ''}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
src={rocketChatUrl}
allowFullScreen={true}
/>
</div>
</main>

View File

@ -1,7 +1,7 @@
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { redirect } from "next/navigation";
import { ResponsiveIframe } from "@/app/components/responsive-iframe";
import ResponsiveIframe from "@/app/components/responsive-iframe";
export default async function Page() {
const session = await getServerSession(authOptions);
@ -15,7 +15,7 @@ export default async function Page() {
<div className="w-full h-full px-4 pt-12 pb-4">
<ResponsiveIframe
src={process.env.NEXT_PUBLIC_IFRAME_MISSIONVIEW_URL || ''}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen={true}
/>
</div>
</main>

View File

@ -1,7 +1,7 @@
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { redirect } from "next/navigation";
import { ResponsiveIframe } from "@/app/components/responsive-iframe";
import ResponsiveIframe from "@/app/components/responsive-iframe";
export default async function Page() {
const session = await getServerSession(authOptions);
@ -15,7 +15,7 @@ export default async function Page() {
<div className="w-full h-full px-4 pt-12 pb-4">
<ResponsiveIframe
src={process.env.NEXT_PUBLIC_IFRAME_RADIO_URL || ''}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen={true}
/>
</div>
</main>

View File

@ -1,7 +1,7 @@
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { redirect } from "next/navigation";
import { ResponsiveIframe } from "@/app/components/responsive-iframe";
import ResponsiveIframe from "@/app/components/responsive-iframe";
export default async function Page() {
const session = await getServerSession(authOptions);
@ -15,7 +15,7 @@ export default async function Page() {
<div className="w-full h-full px-4 pt-12 pb-4">
<ResponsiveIframe
src={process.env.NEXT_PUBLIC_IFRAME_SHOWCASE_URL || ''}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen={true}
/>
</div>
</main>

View File

@ -1,47 +1,68 @@
"use client";
import { signIn, useSession } from "next-auth/react";
import { useEffect } from "react";
import { signIn } from "next-auth/react";
import { useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { clearAuthCookies } from "@/lib/session";
export default function SignIn() {
const { data: session } = useSession();
const searchParams = useSearchParams();
const error = searchParams.get("error");
const [message, setMessage] = useState("");
useEffect(() => {
// Trigger Keycloak sign-in
signIn("keycloak", { callbackUrl: "/" });
}, []);
useEffect(() => {
if (session?.user && !session.user.nextcloudInitialized) {
// Initialize Nextcloud
fetch('/api/nextcloud/init', {
method: 'POST'
}).then(response => {
if (!response.ok) {
console.error('Failed to initialize Nextcloud');
}
}).catch(error => {
console.error('Error initializing Nextcloud:', error);
});
// Always clear cookies on signin page load to ensure fresh authentication
clearAuthCookies();
// Set error message if present
if (error) {
console.log("Clearing auth cookies due to error:", error);
if (error === "RefreshTokenError" || error === "invalid_grant") {
setMessage("Your session has expired. Please sign in again.");
} else {
setMessage("There was a problem with authentication. Please sign in.");
}
}
}, [session]);
}, [error]);
// Login function with callbackUrl to maintain original destination
const handleSignIn = () => {
// Get the callback URL from the query parameters or use the home page
const callbackUrl = searchParams.get("callbackUrl") || "/";
// Add a timestamp parameter to avoid caching issues
const timestamp = new Date().getTime();
const authParams = {
callbackUrl,
redirect: true,
// Adding a timestamp to force Keycloak to skip any cached session
authParams: {
prompt: "login",
t: timestamp.toString()
}
};
signIn("keycloak", authParams);
};
return (
<div
className="min-h-screen flex items-center justify-center"
style={{
backgroundImage: "url('/signin.jpg')",
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat'
}}
>
<div className="w-full max-w-md space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-white">
Redirecting to login...
</h2>
</div>
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<div className="max-w-md w-full p-8 bg-white rounded-lg shadow-lg">
<h1 className="text-2xl font-bold text-center mb-6">Sign In</h1>
{message && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{message}
</div>
)}
<button
onClick={handleSignIn}
className="w-full bg-blue-500 hover:bg-blue-600 text-white font-semibold py-2 px-4 rounded"
>
Sign in with Keycloak
</button>
</div>
</div>
);

View File

@ -0,0 +1,56 @@
"use client";
import { useEffect } from 'react';
import { useSession } from 'next-auth/react';
export default function SilentRefresh() {
const { data: session, status } = useSession();
useEffect(() => {
// Notify parent window of authentication status
const notifyParent = (statusType: string) => {
try {
// Post message to parent window
if (window.parent && window.parent !== window) {
window.parent.postMessage({
type: 'AUTH_STATUS',
status: statusType,
timestamp: Date.now()
}, '*');
console.log(`Silent refresh: notified parent of ${statusType} status`);
}
} catch (error) {
console.error('Error notifying parent window:', error);
}
};
// When session status changes, notify parent
if (status === 'authenticated' && session) {
// User is authenticated
notifyParent('AUTHENTICATED');
} else if (status === 'unauthenticated') {
// User is not authenticated
notifyParent('UNAUTHENTICATED');
}
// Set up automatic cleanup
const timeout = setTimeout(() => {
// Notify parent we're cleaning up (in case we're in loading state forever)
notifyParent('CLEANUP');
}, 10000); // 10 second timeout
return () => clearTimeout(timeout);
}, [session, status]);
return (
<div className="p-4 text-center">
<h1 className="text-lg font-medium">Silent Authentication Check</h1>
<p className="text-sm text-gray-500 mt-2">
{status === 'loading' && 'Checking authentication status...'}
{status === 'authenticated' && 'You are authenticated.'}
{status === 'unauthenticated' && 'You are not authenticated.'}
</p>
</div>
);
}

View File

@ -1,7 +1,7 @@
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { redirect } from "next/navigation";
import { ResponsiveIframe } from "@/app/components/responsive-iframe";
import ResponsiveIframe from "@/app/components/responsive-iframe";
export default async function Page() {
const session = await getServerSession(authOptions);
@ -15,7 +15,7 @@ export default async function Page() {
<div className="w-full h-full px-4 pt-12 pb-4">
<ResponsiveIframe
src={process.env.NEXT_PUBLIC_IFRAME_THEMESSAGE_URL || ''}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen={true}
/>
</div>
</main>

View File

@ -1,7 +1,7 @@
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { redirect } from "next/navigation";
import { ResponsiveIframe } from "@/app/components/responsive-iframe";
import ResponsiveIframe from "@/app/components/responsive-iframe";
export default async function Page() {
const session = await getServerSession(authOptions);
@ -15,7 +15,7 @@ export default async function Page() {
<div className="w-full h-full px-4 pt-12 pb-4">
<ResponsiveIframe
src={process.env.NEXT_PUBLIC_IFRAME_CONFERENCE_URL || ''}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen={true}
/>
</div>
</main>

View File

@ -1,587 +0,0 @@
'use client';
import { useRef, useEffect, useState, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Paperclip, X } from 'lucide-react';
import { Textarea } from '@/components/ui/textarea';
import { decodeComposeContent, encodeComposeContent } from '@/lib/compose-mime-decoder';
import { Email } from '@/app/courrier/page';
import mime from 'mime';
import { simpleParser } from 'mailparser';
import { decodeEmail } from '@/lib/mail-parser-wrapper';
import DOMPurify from 'dompurify';
interface ComposeEmailProps {
showCompose: boolean;
setShowCompose: (show: boolean) => void;
composeTo: string;
setComposeTo: (to: string) => void;
composeCc: string;
setComposeCc: (cc: string) => void;
composeBcc: string;
setComposeBcc: (bcc: string) => void;
composeSubject: string;
setComposeSubject: (subject: string) => void;
composeBody: string;
setComposeBody: (body: string) => void;
showCc: boolean;
setShowCc: (show: boolean) => void;
showBcc: boolean;
setShowBcc: (show: boolean) => void;
attachments: any[];
setAttachments: (attachments: any[]) => void;
handleSend: () => Promise<void>;
originalEmail?: {
content: string;
type: 'reply' | 'reply-all' | 'forward';
};
onSend: (email: Email) => void;
onCancel: () => void;
onBodyChange?: (body: string) => void;
initialTo?: string;
initialSubject?: string;
initialBody?: string;
initialCc?: string;
initialBcc?: string;
replyTo?: Email | null;
forwardFrom?: Email | null;
}
export default function ComposeEmail({
showCompose,
setShowCompose,
composeTo,
setComposeTo,
composeCc,
setComposeCc,
composeBcc,
setComposeBcc,
composeSubject,
setComposeSubject,
composeBody,
setComposeBody,
showCc,
setShowCc,
showBcc,
setShowBcc,
attachments,
setAttachments,
handleSend,
originalEmail,
onSend,
onCancel,
onBodyChange,
initialTo,
initialSubject,
initialBody,
initialCc,
initialBcc,
replyTo,
forwardFrom
}: ComposeEmailProps) {
const composeBodyRef = useRef<HTMLDivElement>(null);
const [localContent, setLocalContent] = useState('');
const [isLoading, setIsLoading] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (replyTo || forwardFrom) {
const initializeContent = async () => {
if (!composeBodyRef.current) return;
try {
const emailToProcess = replyTo || forwardFrom;
console.log('[DEBUG] Initializing compose content with email:',
emailToProcess ? {
id: emailToProcess.id,
subject: emailToProcess.subject,
hasContent: !!emailToProcess.content,
contentLength: emailToProcess.content ? emailToProcess.content.length : 0,
preview: emailToProcess.preview
} : 'null'
);
// Set initial loading state
composeBodyRef.current.innerHTML = `
<div class="compose-area" contenteditable="true">
<br/>
<div class="text-gray-500">Loading original message...</div>
</div>
`;
setIsLoading(true);
// Check if email object exists
if (!emailToProcess) {
console.error('[DEBUG] No email to process for reply/forward');
composeBodyRef.current.innerHTML = `
<div class="compose-area" contenteditable="true">
<br/>
<div style="color: #ef4444;">No email selected for reply/forward.</div>
</div>
`;
setIsLoading(false);
return;
}
// Check if we need to fetch full content first
if (!emailToProcess.content || emailToProcess.content.length === 0) {
console.log('[DEBUG] Need to fetch content before composing reply/forward');
try {
const response = await fetch(`/api/courrier/${emailToProcess.id}?folder=${encodeURIComponent(emailToProcess.folder || 'INBOX')}`);
if (!response.ok) {
throw new Error(`Failed to fetch email content: ${response.status}`);
}
const fullContent = await response.json();
// Update the email content with the fetched full content
emailToProcess.content = fullContent.content;
emailToProcess.contentFetched = true;
console.log('[DEBUG] Successfully fetched content for reply/forward');
} catch (error) {
console.error('[DEBUG] Error fetching content for reply:', error);
composeBodyRef.current.innerHTML = `
<div class="compose-area" contenteditable="true">
<br/>
<div style="color: #ef4444;">Failed to load email content. Please try again.</div>
</div>
`;
setIsLoading(false);
return; // Exit if we couldn't get the content
}
}
// Use the exact same implementation as Panel 3's ReplyContent
try {
const decoded = await decodeEmail(emailToProcess.content);
let formattedContent = '';
if (forwardFrom) {
// Create a clean header for the forwarded email
const headerHtml = `
<div style="border-bottom: 1px solid #e2e2e2; margin-bottom: 15px; padding-bottom: 15px; font-family: Arial, sans-serif;">
<p style="margin: 4px 0;">---------- Forwarded message ---------</p>
<p style="margin: 4px 0;"><b>From:</b> ${decoded.from || ''}</p>
<p style="margin: 4px 0;"><b>Date:</b> ${formatDate(decoded.date)}</p>
<p style="margin: 4px 0;"><b>Subject:</b> ${decoded.subject || ''}</p>
<p style="margin: 4px 0;"><b>To:</b> ${decoded.to || ''}</p>
</div>
`;
// Use the original HTML as-is without DOMPurify or any modification
formattedContent = `
${headerHtml}
${decoded.html || decoded.text || 'No content available'}
`;
} else {
formattedContent = `
<div class="quoted-message">
<p>On ${formatDate(decoded.date)}, ${decoded.from || ''} wrote:</p>
<blockquote>
<div class="email-content prose prose-sm max-w-none dark:prose-invert">
${decoded.html || `<pre>${decoded.text || ''}</pre>`}
</div>
</blockquote>
</div>
`;
}
// Set the content in the compose area with proper structure
const wrappedContent = `
<div class="compose-area" contenteditable="true" style="min-height: 100px; padding: 10px;">
<div style="min-height: 20px;"><br/></div>
${formattedContent}
</div>
`;
if (composeBodyRef.current) {
composeBodyRef.current.innerHTML = wrappedContent;
// Place cursor at the beginning before the quoted content
const selection = window.getSelection();
const range = document.createRange();
const firstDiv = composeBodyRef.current.querySelector('div[style*="min-height: 20px;"]');
if (firstDiv) {
range.setStart(firstDiv, 0);
range.collapse(true);
selection?.removeAllRanges();
selection?.addRange(range);
(firstDiv as HTMLElement).focus();
}
// Update compose state
setComposeBody(wrappedContent);
setLocalContent(wrappedContent);
console.log('[DEBUG] Successfully set compose content');
}
} catch (error) {
console.error('[DEBUG] Error parsing email for compose:', error);
// Fallback to basic content display
const errorContent = `
<div class="compose-area" contenteditable="true">
<br/>
<div style="color: #64748b;">
---------- Original Message ---------<br/>
${emailToProcess.subject ? `Subject: ${emailToProcess.subject}<br/>` : ''}
${emailToProcess.from ? `From: ${emailToProcess.from}<br/>` : ''}
${emailToProcess.date ? `Date: ${new Date(emailToProcess.date).toLocaleString()}<br/>` : ''}
</div>
<div style="color: #64748b; border-left: 2px solid #e5e7eb; padding-left: 10px; margin: 10px 0;">
${emailToProcess.preview || 'No content available'}
</div>
</div>
`;
if (composeBodyRef.current) {
composeBodyRef.current.innerHTML = errorContent;
setComposeBody(errorContent);
setLocalContent(errorContent);
}
}
} catch (error) {
console.error('[DEBUG] Error initializing compose content:', error);
if (composeBodyRef.current) {
const errorContent = `
<div class="compose-area" contenteditable="true">
<br/>
<div style="color: #ef4444;">Error loading original message.</div>
<div style="color: #64748b; font-size: 0.875rem; margin-top: 0.5rem;">
Technical details: ${error instanceof Error ? error.message : 'Unknown error'}
</div>
</div>
`;
composeBodyRef.current.innerHTML = errorContent;
setComposeBody(errorContent);
setLocalContent(errorContent);
}
} finally {
setIsLoading(false);
}
};
initializeContent();
}
}, [replyTo, forwardFrom, setComposeBody]);
const handleInput = (e: React.FormEvent<HTMLDivElement>) => {
if (!e.currentTarget) return;
const content = e.currentTarget.innerHTML;
if (!content.trim()) {
setLocalContent('');
setComposeBody('');
} else {
setLocalContent(content);
setComposeBody(content);
}
if (onBodyChange) {
onBodyChange(content);
}
// Ensure scrolling and cursor behavior works after edits
const messageContentDivs = e.currentTarget.querySelectorAll('.message-content');
messageContentDivs.forEach(div => {
// Make sure the div remains scrollable after input events
(div as HTMLElement).style.maxHeight = '300px';
(div as HTMLElement).style.overflowY = 'auto';
(div as HTMLElement).style.border = '1px solid #e5e7eb';
(div as HTMLElement).style.borderRadius = '4px';
(div as HTMLElement).style.padding = '10px';
// Ensure wheel events are properly handled
if (!(div as HTMLElement).hasAttribute('data-scroll-handler-attached')) {
div.addEventListener('wheel', function(this: HTMLElement, ev: Event) {
const e = ev as WheelEvent;
const target = this;
// Check if we're at the boundary of the scrollable area
const isAtBottom = target.scrollHeight - target.scrollTop <= target.clientHeight + 1;
const isAtTop = target.scrollTop <= 0;
// Only prevent default if we're not at the boundaries in the direction of scrolling
if ((e.deltaY > 0 && !isAtBottom) || (e.deltaY < 0 && !isAtTop)) {
e.stopPropagation();
e.preventDefault(); // Prevent the parent container from scrolling
}
}, { passive: false });
// Mark this element as having a scroll handler attached
(div as HTMLElement).setAttribute('data-scroll-handler-attached', 'true');
}
});
};
const handleSendEmail = async () => {
if (!composeBodyRef.current) return;
const composeArea = composeBodyRef.current.querySelector('.compose-area');
if (!composeArea) return;
const content = composeArea.innerHTML;
if (!content.trim()) {
console.error('Email content is empty');
return;
}
try {
const encodedContent = await encodeComposeContent(content);
setComposeBody(encodedContent);
await handleSend();
setShowCompose(false);
} catch (error) {
console.error('Error sending email:', error);
}
};
const handleFileAttachment = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files) return;
const newAttachments: any[] = [];
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB in bytes
const oversizedFiles: string[] = [];
for (const file of e.target.files) {
if (file.size > MAX_FILE_SIZE) {
oversizedFiles.push(file.name);
continue;
}
try {
// Read file as base64
const base64Content = await new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onloadend = () => {
const base64 = reader.result as string;
resolve(base64.split(',')[1]); // Remove data URL prefix
};
reader.readAsDataURL(file);
});
newAttachments.push({
name: file.name,
type: file.type,
content: base64Content,
encoding: 'base64'
});
} catch (error) {
console.error('Error processing attachment:', error);
}
}
if (oversizedFiles.length > 0) {
alert(`The following files exceed the 10MB size limit and were not attached:\n${oversizedFiles.join('\n')}`);
}
if (newAttachments.length > 0) {
setAttachments([...attachments, ...newAttachments]);
}
};
// Add focus handling for better UX
const handleComposeAreaClick = (e: React.MouseEvent<HTMLDivElement>) => {
// If the click is directly on the compose area and not on any child element
if (e.target === e.currentTarget) {
// Find the cursor position element
const cursorPosition = e.currentTarget.querySelector('.cursor-position');
if (cursorPosition) {
// Focus the cursor position element
(cursorPosition as HTMLElement).focus();
// Set cursor at the beginning
const selection = window.getSelection();
const range = document.createRange();
range.setStart(cursorPosition, 0);
range.collapse(true);
selection?.removeAllRanges();
selection?.addRange(range);
}
}
};
// Add formatDate function to match Panel 3 implementation
function formatDate(date: Date | null): string {
if (!date) return '';
return new Intl.DateTimeFormat('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(date);
}
if (!showCompose) return null;
return (
<div className="fixed inset-0 bg-gray-600/30 backdrop-blur-sm z-50 flex items-center justify-center">
<div className="w-full max-w-2xl h-[90vh] bg-white rounded-xl shadow-xl flex flex-col mx-4">
{/* Modal Header */}
<div className="flex-none flex items-center justify-between px-6 py-3 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900">
{replyTo ? 'Reply' : forwardFrom ? 'Forward' : 'New Message'}
</h3>
<Button
variant="ghost"
size="icon"
className="hover:bg-gray-100 rounded-full"
onClick={onCancel}
>
<X className="h-5 w-5 text-gray-500" />
</Button>
</div>
{/* Modal Body */}
<div className="flex-1 overflow-hidden">
<div className="h-full flex flex-col p-6 space-y-4 overflow-y-auto">
{/* To Field */}
<div className="flex-none">
<Label htmlFor="to" className="block text-sm font-medium text-gray-700">To</Label>
<Input
id="to"
value={composeTo}
onChange={(e) => setComposeTo(e.target.value)}
placeholder="recipient@example.com"
className="w-full mt-1 bg-white border-gray-300 text-gray-900"
/>
</div>
{/* CC/BCC Toggle Buttons */}
<div className="flex-none flex items-center gap-4">
<button
type="button"
className="text-blue-600 hover:text-blue-700 text-sm font-medium"
onClick={() => setShowCc(!showCc)}
>
{showCc ? 'Hide Cc' : 'Add Cc'}
</button>
<button
type="button"
className="text-blue-600 hover:text-blue-700 text-sm font-medium"
onClick={() => setShowBcc(!showBcc)}
>
{showBcc ? 'Hide Bcc' : 'Add Bcc'}
</button>
</div>
{/* CC Field */}
{showCc && (
<div className="flex-none">
<Label htmlFor="cc" className="block text-sm font-medium text-gray-700">Cc</Label>
<Input
id="cc"
value={composeCc}
onChange={(e) => setComposeCc(e.target.value)}
placeholder="cc@example.com"
className="w-full mt-1 bg-white border-gray-300 text-gray-900"
/>
</div>
)}
{/* BCC Field */}
{showBcc && (
<div className="flex-none">
<Label htmlFor="bcc" className="block text-sm font-medium text-gray-700">Bcc</Label>
<Input
id="bcc"
value={composeBcc}
onChange={(e) => setComposeBcc(e.target.value)}
placeholder="bcc@example.com"
className="w-full mt-1 bg-white border-gray-300 text-gray-900"
/>
</div>
)}
{/* Subject Field */}
<div className="flex-none">
<Label htmlFor="subject" className="block text-sm font-medium text-gray-700">Subject</Label>
<Input
id="subject"
value={composeSubject}
onChange={(e) => setComposeSubject(e.target.value)}
placeholder="Enter subject"
className="w-full mt-1 bg-white border-gray-300 text-gray-900"
/>
</div>
{/* Message Body */}
<div className="flex-1 min-h-[200px] flex flex-col">
<Label htmlFor="message" className="flex-none block text-sm font-medium text-gray-700 mb-2">Message</Label>
<div
ref={composeBodyRef}
contentEditable="true"
onInput={handleInput}
onClick={handleComposeAreaClick}
className="flex-1 w-full bg-white border border-gray-300 rounded-md p-4 text-black overflow-y-auto focus:outline-none focus:ring-1 focus:ring-blue-500"
style={{
direction: 'ltr',
maxHeight: 'calc(100vh - 400px)',
minHeight: '200px',
overflowY: 'auto',
scrollbarWidth: 'thin',
scrollbarColor: '#cbd5e0 #f3f4f6',
cursor: 'text'
}}
dir="ltr"
spellCheck="true"
role="textbox"
aria-multiline="true"
tabIndex={0}
suppressContentEditableWarning={true}
/>
</div>
</div>
</div>
{/* Modal Footer */}
<div className="flex-none flex items-center justify-between px-6 py-3 border-t border-gray-200 bg-white">
<div className="flex items-center gap-2">
{/* File Input for Attachments */}
<input
type="file"
id="file-attachment"
className="hidden"
multiple
onChange={handleFileAttachment}
/>
<label htmlFor="file-attachment">
<Button
variant="outline"
size="icon"
className="rounded-full bg-white hover:bg-gray-100 border-gray-300"
onClick={(e) => {
e.preventDefault();
document.getElementById('file-attachment')?.click();
}}
>
<Paperclip className="h-4 w-4 text-gray-600" />
</Button>
</label>
</div>
<div className="flex items-center gap-3">
<Button
variant="ghost"
className="text-gray-600 hover:text-gray-700 hover:bg-gray-100"
onClick={onCancel}
>
Cancel
</Button>
<Button
className="bg-blue-600 text-white hover:bg-blue-700"
onClick={handleSendEmail}
>
Send
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@ -5,23 +5,31 @@ import { usePathname, useRouter } from "next/navigation";
import { useEffect } from "react";
export function AuthCheck({ children }: { children: React.ReactNode }) {
const { data: session, status } = useSession();
const { status } = useSession();
const pathname = usePathname();
const router = useRouter();
useEffect(() => {
if (status === "unauthenticated" && pathname !== "/signin") {
// Simple redirect to login page if not authenticated
if (status === "unauthenticated" && !pathname.includes("/signin")) {
router.push("/signin");
}
}, [status, router, pathname]);
// Simple loading state
if (status === "loading") {
return <div>Chargement...</div>;
return (
<div className="flex justify-center items-center min-h-screen">
<div className="animate-spin h-10 w-10 border-4 border-blue-500 rounded-full border-t-transparent"></div>
</div>
);
}
if (status === "unauthenticated" && pathname !== "/signin") {
// Don't render on unauthenticated
if (status === "unauthenticated" && !pathname.includes("/signin")) {
return null;
}
// Render children if authenticated
return <>{children}</>;
}

View File

@ -1,24 +1,102 @@
"use client";
import { useEffect } from "react";
import { signOut } from "next-auth/react";
import { useSession, signOut } from "next-auth/react";
import { clearAuthCookies } from "@/lib/session";
export function SignOutHandler() {
const { data: session } = useSession();
useEffect(() => {
const handleSignOut = async () => {
// Clear only auth-related cookies
clearAuthCookies();
// Then sign out from NextAuth
await signOut({
callbackUrl: "/signin",
redirect: true
});
try {
// Store the user ID before signout clears the session
const userId = session?.user?.id;
console.log('Starting optimized logout process (preserving SSO)');
// First trigger server-side Redis cleanup only
if (userId) {
console.log('Triggering server-side Redis cleanup');
try {
const cleanupResponse = await fetch('/api/auth/session-cleanup', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
userId,
preserveSso: true
}),
credentials: 'include'
});
const cleanupResult = await cleanupResponse.json();
console.log('Server cleanup result:', cleanupResult);
} catch (cleanupError) {
console.error('Error during server-side cleanup:', cleanupError);
}
}
// Use NextAuth signOut but ONLY for our application cookies
// This preserves the Keycloak SSO session for other services
await signOut({ redirect: false });
// Clear ONLY application-specific cookies (not Keycloak SSO cookies)
const appCookies = [
'next-auth.session-token',
'next-auth.csrf-token',
'next-auth.callback-url',
'__Secure-next-auth.session-token',
'__Host-next-auth.csrf-token',
];
// Only clear chunked cookies for our app
const cookies = document.cookie.split(';');
const chunkedCookies = cookies
.map(cookie => cookie.split('=')[0].trim())
.filter(name => /next-auth.*\.\d+$/.test(name));
[...appCookies, ...chunkedCookies].forEach(cookieName => {
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=${window.location.hostname}; SameSite=None; Secure`;
});
// Clear Rocket Chat authentication tokens
try {
console.log('Clearing Rocket Chat tokens');
// Remove cookies
document.cookie = `rc_token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=${window.location.hostname}; SameSite=None; Secure`;
document.cookie = `rc_uid=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=${window.location.hostname}; SameSite=None; Secure`;
// Remove localStorage items
localStorage.removeItem('Meteor.loginToken');
localStorage.removeItem('Meteor.userId');
} catch (e) {
console.error('Error clearing Rocket Chat tokens:', e);
}
// Redirect to loggedout page
window.location.href = '/loggedout?preserveSso=true';
} catch (error) {
console.error('Error during logout:', error);
window.location.href = '/loggedout?preserveSso=true';
}
};
handleSignOut();
}, []);
// Add a slight delay to ensure useSession has loaded
const timer = setTimeout(() => {
handleSignOut();
}, 100);
return () => clearTimeout(timer);
}, [session]);
return null;
return (
<div className="w-full h-full flex items-center justify-center">
<div className="text-center">
<h2 className="text-2xl font-bold">Logging out...</h2>
<p className="text-gray-500 mt-2">Please wait while we sign you out of this application.</p>
</div>
</div>
);
}

View File

@ -1,195 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { format, isToday, isTomorrow, addDays } from "date-fns";
import { fr } from "date-fns/locale";
import { CalendarIcon, ClockIcon, ChevronRight } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { useSession } from "next-auth/react";
type Event = {
id: string;
title: string;
start: string;
end: string;
isAllDay: boolean;
calendarId: string;
calendarName?: string;
calendarColor?: string;
};
export function CalendarWidget() {
const { data: session } = useSession();
const [events, setEvents] = useState<Event[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
// Ne charger les événements que si l'utilisateur est connecté
if (!session) return;
const fetchUpcomingEvents = async () => {
try {
setLoading(true);
// Récupérer d'abord les calendriers de l'utilisateur
const calendarsRes = await fetch("/api/calendars");
if (!calendarsRes.ok) {
throw new Error("Impossible de charger les calendriers");
}
const calendars = await calendarsRes.json();
if (calendars.length === 0) {
setEvents([]);
setLoading(false);
return;
}
// Date actuelle et date dans 7 jours
const now = new Date();
// @ts-ignore
const nextWeek = addDays(now, 7);
// Récupérer les événements pour chaque calendrier
const allEventsPromises = calendars.map(async (calendar: any) => {
const eventsRes = await fetch(
`/api/calendars/${
calendar.id
}/events?start=${now.toISOString()}&end=${nextWeek.toISOString()}`
);
if (!eventsRes.ok) {
console.warn(
`Impossible de charger les événements du calendrier ${calendar.id}`
);
return [];
}
const events = await eventsRes.json();
// Ajouter les informations du calendrier à chaque événement
return events.map((event: any) => ({
...event,
calendarName: calendar.name,
calendarColor: calendar.color,
}));
});
// Attendre toutes les requêtes d'événements
const allEventsArrays = await Promise.all(allEventsPromises);
// Fusionner tous les événements en un seul tableau
const allEvents = allEventsArrays.flat();
// Trier par date de début
const sortedEvents = allEvents.sort(
(a, b) => new Date(a.start).getTime() - new Date(b.start).getTime()
);
// Limiter à 5 événements
setEvents(sortedEvents.slice(0, 5));
} catch (err) {
console.error("Erreur lors du chargement des événements:", err);
setError("Impossible de charger les événements à venir");
} finally {
setLoading(false);
}
};
fetchUpcomingEvents();
}, [session]);
// Formater la date d'un événement pour l'affichage
const formatEventDate = (date: string, isAllDay: boolean) => {
const eventDate = new Date(date);
let dateString = "";
// @ts-ignore
if (isToday(eventDate)) {
dateString = "Aujourd'hui";
// @ts-ignore
} else if (isTomorrow(eventDate)) {
dateString = "Demain";
} else {
// @ts-ignore
dateString = format(eventDate, "EEEE d MMMM", { locale: fr });
}
if (!isAllDay) {
// @ts-ignore
dateString += ` · ${format(eventDate, "HH:mm", { locale: fr })}`;
}
return dateString;
};
return (
<Card className='transition-transform duration-500 ease-in-out transform hover:scale-105'>
<CardHeader className='flex flex-row items-center justify-between pb-2'>
<CardTitle className='text-lg font-medium'>
Événements à venir
</CardTitle>
<Link href='/calendar' passHref>
<Button variant='ghost' size='sm' className='h-8 w-8 p-0'>
<ChevronRight className='h-4 w-4' />
<span className='sr-only'>Voir le calendrier</span>
</Button>
</Link>
</CardHeader>
<CardContent className='pb-3'>
{loading ? (
<div className='flex items-center justify-center py-4'>
<div className='h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent' />
<span className='ml-2 text-sm text-muted-foreground'>
Chargement...
</span>
</div>
) : error ? (
<p className='text-sm text-red-500'>{error}</p>
) : events.length === 0 ? (
<p className='text-sm text-muted-foreground py-2'>
Aucun événement à venir cette semaine
</p>
) : (
<div className='space-y-3'>
{events.map((event) => (
<div
key={event.id}
className='flex items-start space-x-3 rounded-md border border-muted p-2'
>
<div
className='h-3 w-3 flex-shrink-0 rounded-full mt-1'
style={{ backgroundColor: event.calendarColor || "#0082c9" }}
/>
<div className='flex-1 min-w-0'>
<h5
className='text-sm font-medium truncate'
title={event.title}
>
{event.title}
</h5>
<div className='flex items-center text-xs text-muted-foreground mt-1'>
<CalendarIcon className='h-3 w-3 mr-1' />
<span>{formatEventDate(event.start, event.isAllDay)}</span>
</div>
</div>
</div>
))}
<Link href='/calendar' passHref>
<Button
size='sm'
className='w-full transition-all ease-in-out duration-500 bg-muted text-black hover:text-white hover:bg-primary'
>
Voir tous les événements
</Button>
</Link>
</div>
)}
</CardContent>
</Card>
);
}

View File

@ -1,107 +1,77 @@
"use client";
import { useEffect, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { RefreshCw, Calendar as CalendarIcon } from "lucide-react";
import { RefreshCw, Calendar as CalendarIcon, ChevronRight } from "lucide-react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { format, isToday, isTomorrow } from "date-fns";
import { fr } from "date-fns/locale";
import { useCalendarEvents, CalendarEvent } from "@/hooks/use-calendar-events";
interface Event {
id: string;
title: string;
start: string;
end: string;
allDay: boolean;
calendar: string;
calendarColor: string;
interface CalendarProps {
limit?: number;
showMore?: boolean;
showRefresh?: boolean;
cardClassName?: string;
}
export function Calendar() {
const [events, setEvents] = useState<Event[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
export function Calendar({
limit = 5,
showMore = true,
showRefresh = true,
cardClassName = "transition-transform duration-500 ease-in-out transform hover:scale-105 bg-white/95 backdrop-blur-sm border-0 shadow-lg"
}: CalendarProps) {
const { events, loading, error, refresh } = useCalendarEvents({ limit });
const router = useRouter();
const fetchEvents = async () => {
setLoading(true);
try {
const response = await fetch('/api/calendars');
if (!response.ok) {
throw new Error('Failed to fetch events');
}
const calendarsData = await response.json();
console.log('Calendar Widget - Fetched calendars:', calendarsData);
const formatEventDate = (date: Date | string, isAllDay: boolean) => {
const eventDate = date instanceof Date ? date : new Date(date);
let dateString = "";
// Get current date at the start of the day
const now = new Date();
now.setHours(0, 0, 0, 0);
// Extract and process events from all calendars
const allEvents = calendarsData.flatMap((calendar: any) =>
(calendar.events || []).map((event: any) => ({
id: event.id,
title: event.title,
start: event.start,
end: event.end,
allDay: event.isAllDay,
calendar: calendar.name,
calendarColor: calendar.color
}))
);
// Filter for upcoming events
const upcomingEvents = allEvents
.filter((event: any) => new Date(event.start) >= now)
.sort((a: any, b: any) => new Date(a.start).getTime() - new Date(b.start).getTime())
.slice(0, 7);
console.log('Calendar Widget - Processed events:', upcomingEvents);
setEvents(upcomingEvents);
setError(null);
} catch (err) {
console.error('Error fetching events:', err);
setError('Failed to load events');
} finally {
setLoading(false);
if (isToday(eventDate)) {
dateString = "Today";
} else if (isTomorrow(eventDate)) {
dateString = "Tomorrow";
} else {
dateString = format(eventDate, "EEEE d MMMM", { locale: fr });
}
};
useEffect(() => {
fetchEvents();
}, []);
if (!isAllDay) {
dateString += ` · ${format(eventDate, "HH:mm", { locale: fr })}`;
}
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return new Intl.DateTimeFormat('fr-FR', {
day: '2-digit',
month: 'short'
}).format(date);
};
const formatTime = (dateString: string) => {
const date = new Date(dateString);
return new Intl.DateTimeFormat('fr-FR', {
hour: '2-digit',
minute: '2-digit',
}).format(date);
return dateString;
};
return (
<Card className="transition-transform duration-500 ease-in-out transform hover:scale-105 bg-white/95 backdrop-blur-sm border-0 shadow-lg">
<Card className={cardClassName}>
<CardHeader className="flex flex-row items-center justify-between pb-2 border-b border-gray-100">
<CardTitle className="text-lg font-semibold text-gray-800 flex items-center gap-2">
<CalendarIcon className="h-5 w-5 text-gray-600" />
Agenda
Calendar
</CardTitle>
<Button
variant="ghost"
size="icon"
onClick={() => fetchEvents()}
className="h-7 w-7 p-0 hover:bg-gray-100/50 rounded-full"
>
<RefreshCw className="h-3.5 w-3.5 text-gray-600" />
</Button>
<div className="flex items-center gap-1">
{showRefresh && (
<Button
variant="ghost"
size="icon"
onClick={refresh}
className="h-7 w-7 p-0 hover:bg-gray-100/50 rounded-full"
disabled={loading}
>
<RefreshCw className="h-3.5 w-3.5 text-gray-600" />
</Button>
)}
{showMore && (
<Link href='/agenda' passHref>
<Button variant='ghost' size='sm' className='h-8 w-8 p-0'>
<ChevronRight className='h-4 w-4' />
<span className='sr-only'>View calendar</span>
</Button>
</Link>
)}
</div>
</CardHeader>
<CardContent className="p-3">
{loading ? (
@ -123,21 +93,21 @@ export function Calendar() {
<div
className="flex-shrink-0 w-14 h-14 rounded-lg flex flex-col items-center justify-center border"
style={{
backgroundColor: `${event.calendarColor}10`,
borderColor: event.calendarColor
backgroundColor: `${event.calendarColor || '#4F46E5'}10`,
borderColor: event.calendarColor || '#4F46E5'
}}
>
<span
className="text-[10px] font-medium"
style={{ color: event.calendarColor }}
style={{ color: event.calendarColor || '#4F46E5' }}
>
{formatDate(event.start)}
{format(event.start instanceof Date ? event.start : new Date(event.start), 'MMM', { locale: fr })}
</span>
<span
className="text-[10px] font-bold mt-0.5"
style={{ color: event.calendarColor }}
style={{ color: event.calendarColor || '#4F46E5' }}
>
{formatTime(event.start)}
{format(event.start instanceof Date ? event.start : new Date(event.start), 'dd', { locale: fr })}
</span>
</div>
<div className="flex-1 min-w-0 space-y-1">
@ -145,25 +115,36 @@ export function Calendar() {
<p className="text-sm font-medium text-gray-800 line-clamp-2 flex-1">
{event.title}
</p>
{!event.allDay && (
{!event.isAllDay && (
<span className="text-[10px] text-gray-500 whitespace-nowrap">
{formatTime(event.start)} - {formatTime(event.end)}
{format(event.start instanceof Date ? event.start : new Date(event.start), 'HH:mm', { locale: fr })} - {format(event.end instanceof Date ? event.end : new Date(event.end), 'HH:mm', { locale: fr })}
</span>
)}
</div>
<div
className="flex items-center text-[10px] px-1.5 py-0.5 rounded-md"
style={{
backgroundColor: `${event.calendarColor}10`,
color: event.calendarColor
backgroundColor: `${event.calendarColor || '#4F46E5'}10`,
color: event.calendarColor || '#4F46E5'
}}
>
<span className="truncate">{event.calendar}</span>
<span className="truncate">{event.calendarName || 'Calendar'}</span>
</div>
</div>
</div>
</div>
))}
{showMore && (
<Link href='/agenda' passHref>
<Button
size='sm'
className='w-full transition-all ease-in-out duration-500 bg-gray-100 text-gray-700 hover:bg-gray-200 mt-2'
>
View all events
</Button>
</Link>
)}
</div>
)}
</CardContent>

View File

@ -0,0 +1,49 @@
'use client';
import { useState, useEffect } from 'react';
/**
* Debug component to show Redis connection status
* Only visible in development mode
*/
export function RedisCacheStatus() {
const [status, setStatus] = useState<'connected' | 'error' | 'loading'>('loading');
const [lastCheck, setLastCheck] = useState<string>('');
useEffect(() => {
async function checkRedis() {
try {
setStatus('loading');
const response = await fetch('/api/redis/status');
const data = await response.json();
setStatus(data.status);
setLastCheck(new Date().toLocaleTimeString());
} catch (e) {
setStatus('error');
setLastCheck(new Date().toLocaleTimeString());
}
}
checkRedis();
const interval = setInterval(checkRedis, 30000); // Check every 30 seconds
return () => clearInterval(interval);
}, []);
// Only show in development mode
if (process.env.NODE_ENV !== 'development') {
return null;
}
return (
<div className="fixed bottom-4 left-4 text-xs bg-gray-800/80 text-white p-2 rounded shadow-md z-50">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${
status === 'connected' ? 'bg-green-500' :
status === 'loading' ? 'bg-yellow-500' : 'bg-red-500'
}`}></div>
<span>Redis: {status}</span>
<span className="opacity-60">({lastCheck})</span>
</div>
</div>
);
}

View File

@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { RefreshCw, MessageSquare, Mail, MailOpen, Loader2 } from "lucide-react";
import Link from 'next/link';
import { useSession } from "next-auth/react";
interface Email {
id: string;
@ -17,28 +18,43 @@ interface Email {
folder: string;
}
interface EmailResponse {
emails: Email[];
mailUrl: string;
error?: string;
interface EmailProps {
limit?: number;
showTitle?: boolean;
showRefresh?: boolean;
showMoreLink?: boolean;
folder?: string;
cardClassName?: string;
title?: string;
}
export function Email() {
export function Email({
limit = 5,
showTitle = true,
showRefresh = true,
showMoreLink = true,
folder = "INBOX",
cardClassName = "h-full",
title = "Unread Emails"
}: EmailProps) {
const [emails, setEmails] = useState<Email[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [mailUrl, setMailUrl] = useState<string | null>(null);
const { status } = useSession();
useEffect(() => {
fetchEmails();
}, []);
if (status === "authenticated") {
fetchEmails();
}
}, [status, folder, limit]);
const fetchEmails = async (isRefresh = false) => {
setLoading(true);
setError(null);
try {
const response = await fetch('/api/courrier?folder=INBOX&page=1&perPage=5');
const response = await fetch(`/api/courrier?folder=${folder}&page=1&perPage=${limit}`);
if (!response.ok) {
throw new Error('Failed to fetch emails');
}
@ -52,20 +68,19 @@ export function Email() {
// Transform data format if needed
const transformedEmails = data.emails.map((email: any) => ({
id: email.id,
subject: email.subject,
subject: email.subject || '(No subject)',
from: email.from[0]?.address || '',
fromName: email.from[0]?.name || '',
date: email.date,
read: email.flags.seen,
starred: email.flags.flagged,
folder: email.folder
})).slice(0, 5); // Only show the first 5 emails
})).slice(0, limit);
setEmails(transformedEmails);
setMailUrl('/courrier');
}
} catch (error) {
console.error('Error fetching emails:', error);
setError('Failed to load emails');
setEmails([]);
} finally {
@ -85,68 +100,88 @@ export function Email() {
}
};
return (
<Card className="h-full">
<CardHeader className="flex flex-row items-center justify-between pb-2 border-b border-gray-100">
<CardTitle className="text-lg font-semibold text-gray-800 flex items-center gap-2">
<MessageSquare className="h-5 w-5 text-gray-600" />
Emails non lus
</CardTitle>
<Button
variant="ghost"
size="icon"
onClick={() => fetchEmails(true)}
disabled={loading}
>
{loading ?
<Loader2 className="h-4 w-4 animate-spin" /> :
<RefreshCw className="h-4 w-4" />
}
</Button>
</CardHeader>
<CardContent className="p-4">
{error ? (
<div className="text-center py-4 text-gray-500">
{error}
</div>
) : loading && emails.length === 0 ? (
<div className="text-center py-6 flex flex-col items-center">
<Loader2 className="h-6 w-6 animate-spin text-gray-400 mb-2" />
<p className="text-gray-500">Chargement des emails...</p>
</div>
) : emails.length === 0 ? (
<div className="text-center py-6">
<p className="text-gray-500">Aucun email non lu</p>
</div>
) : (
<div className="space-y-3">
{emails.map((email) => (
<div key={email.id} className="flex items-start gap-3 py-1 border-b border-gray-100 last:border-0">
<div className="pt-1">
{email.read ?
<MailOpen className="h-4 w-4 text-gray-400" /> :
<Mail className="h-4 w-4 text-blue-500" />
}
</div>
<div className="flex-1 min-w-0">
<div className="flex justify-between">
<p className="font-medium truncate" style={{maxWidth: '180px'}}>{email.fromName || email.from.split('@')[0]}</p>
<p className="text-xs text-gray-500">{formatDate(email.date)}</p>
</div>
<p className="text-sm text-gray-700 truncate">{email.subject}</p>
</div>
const renderContent = () => {
if (error) {
return (
<div className="text-center py-4 text-gray-500">
{error}
</div>
);
}
if (loading && emails.length === 0) {
return (
<div className="text-center py-6 flex flex-col items-center">
<Loader2 className="h-6 w-6 animate-spin text-gray-400 mb-2" />
<p className="text-gray-500">Loading emails...</p>
</div>
);
}
if (emails.length === 0) {
return (
<div className="text-center py-6">
<p className="text-gray-500">No emails found</p>
</div>
);
}
return (
<div className="space-y-3">
{emails.map((email) => (
<div key={email.id} className="flex items-start gap-3 py-1 border-b border-gray-100 last:border-0">
<div className="pt-1">
{email.read ?
<MailOpen className="h-4 w-4 text-gray-400" /> :
<Mail className="h-4 w-4 text-blue-500" />
}
</div>
<div className="flex-1 min-w-0">
<div className="flex justify-between">
<p className="font-medium truncate" style={{maxWidth: '180px'}}>{email.fromName || email.from.split('@')[0]}</p>
<p className="text-xs text-gray-500">{formatDate(email.date)}</p>
</div>
))}
{mailUrl && (
<div className="pt-2">
<Link href={mailUrl} className="text-sm text-blue-600 hover:text-blue-800">
Voir tous les emails
</Link>
</div>
)}
<p className="text-sm text-gray-700 truncate">{email.subject}</p>
</div>
</div>
))}
{showMoreLink && mailUrl && (
<div className="pt-2">
<Link href={mailUrl} className="text-sm text-blue-600 hover:text-blue-800">
View all emails
</Link>
</div>
)}
</div>
);
};
return (
<Card className={cardClassName}>
{showTitle && (
<CardHeader className="flex flex-row items-center justify-between pb-2 border-b border-gray-100">
<CardTitle className="text-lg font-semibold text-gray-800 flex items-center gap-2">
<MessageSquare className="h-5 w-5 text-gray-600" />
{title}
</CardTitle>
{showRefresh && (
<Button
variant="ghost"
size="icon"
onClick={() => fetchEmails(true)}
disabled={loading}
>
{loading ?
<Loader2 className="h-4 w-4 animate-spin" /> :
<RefreshCw className="h-4 w-4" />
}
</Button>
)}
</CardHeader>
)}
<CardContent className="p-4">
{renderContent()}
</CardContent>
</Card>
);

View File

@ -0,0 +1,84 @@
'use client';
import React from 'react';
import { Trash2, Archive, EyeOff } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
interface BulkActionsToolbarProps {
selectedCount: number;
onBulkAction: (action: 'delete' | 'mark-read' | 'mark-unread' | 'archive') => void;
}
export default function BulkActionsToolbar({
selectedCount,
onBulkAction
}: BulkActionsToolbarProps) {
return (
<div className="bg-blue-50 border-b border-blue-100 px-4 py-2 flex items-center justify-between shadow-md transition-all duration-200">
<span className="text-xs font-medium text-blue-700">
{selectedCount} selected
</span>
<div className="flex items-center gap-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-blue-600 hover:text-blue-900 hover:bg-blue-100"
onClick={() => onBulkAction('mark-unread')}
>
<EyeOff className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Mark as unread</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-blue-600 hover:text-blue-900 hover:bg-blue-100"
onClick={() => onBulkAction('archive')}
>
<Archive className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Archive</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-red-600 hover:text-red-700 hover:bg-red-50"
onClick={() => onBulkAction('delete')}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Delete</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,137 @@
'use client';
import { useState, useEffect } from 'react';
import ComposeEmail from './ComposeEmail';
import { EmailMessage } from '@/types/email';
import {
formatReplyEmail,
formatForwardedEmail
} from '@/lib/utils/email-utils';
interface ComposeEmailAdapterProps {
initialEmail?: EmailMessage | null;
type?: 'new' | 'reply' | 'reply-all' | 'forward';
onClose: () => void;
onSend: (emailData: {
to: string;
cc?: string;
bcc?: string;
subject: string;
body: string;
fromAccount?: string;
attachments?: Array<{
name: string;
content: string;
type: string;
}>;
}) => Promise<void>;
accounts?: Array<{
id: string;
email: string;
display_name?: string;
}>;
}
/**
* Adapter component that converts between the new EmailMessage format
* and the format expected by the legacy ComposeEmail component
*/
export default function ComposeEmailAdapter({
initialEmail,
type = 'new',
onClose,
onSend,
accounts = []
}: ComposeEmailAdapterProps) {
// Convert the new EmailMessage format to the old format
const [adaptedEmail, setAdaptedEmail] = useState<any | null>(null);
useEffect(() => {
if (!initialEmail) {
setAdaptedEmail(null);
return;
}
try {
console.log('ComposeEmailAdapter: Processing email for', type, initialEmail);
// For reply or forward, use the email formatting utilities
if (type === 'reply' || type === 'reply-all') {
const formatted = formatReplyEmail(initialEmail, type);
console.log('ComposeEmailAdapter: Formatted reply', formatted);
// Create an object suitable for the legacy ComposeEmail component
const adapted = {
id: initialEmail.id,
subject: formatted.subject,
to: formatted.to,
cc: formatted.cc || '',
content: formatted.content?.html || formatted.content?.text || '',
html: formatted.content?.html,
text: formatted.content?.text,
attachments: initialEmail.attachments || []
};
setAdaptedEmail(adapted);
}
else if (type === 'forward') {
const formatted = formatForwardedEmail(initialEmail);
console.log('ComposeEmailAdapter: Formatted forward', formatted);
// Create an object suitable for the legacy ComposeEmail component
const adapted = {
id: initialEmail.id,
subject: formatted.subject,
to: '',
cc: '',
content: formatted.content?.html || formatted.content?.text || '',
html: formatted.content?.html,
text: formatted.content?.text,
attachments: initialEmail.attachments || []
};
setAdaptedEmail(adapted);
}
else {
// For new emails, just create a minimal template
setAdaptedEmail({
subject: '',
to: '',
cc: '',
content: '',
html: '',
text: '',
attachments: []
});
}
} catch (error) {
console.error('Error adapting email for ComposeEmail:', error);
// Provide a fallback minimal email
setAdaptedEmail({
subject: initialEmail?.subject || '',
to: initialEmail?.from || '',
cc: '',
content: '',
html: '',
text: '',
attachments: []
});
}
}, [initialEmail, type]);
// If still adapting, show loading
if (initialEmail && !adaptedEmail) {
return <div>Loading email...</div>;
}
// Pass the adapted email to the original ComposeEmail component
return (
<ComposeEmail
initialEmail={adaptedEmail}
type={type}
onClose={onClose}
onSend={onSend}
accounts={accounts}
/>
);
}

View File

@ -0,0 +1,53 @@
import React from 'react';
import { SendHorizontal, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface ComposeEmailFooterProps {
sending: boolean;
onSend: () => Promise<void>;
onCancel: () => void;
}
export default function ComposeEmailFooter({
sending,
onSend,
onCancel
}: ComposeEmailFooterProps) {
return (
<div className="p-4 border-t border-gray-200 flex justify-between items-center">
<div className="flex space-x-2">
<Button
type="button"
onClick={onSend}
disabled={sending}
className="bg-blue-600 hover:bg-blue-700"
>
{sending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sending...
</>
) : (
<>
<SendHorizontal className="mr-2 h-4 w-4" />
Send
</>
)}
</Button>
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={sending}
>
Cancel
</Button>
</div>
<div className="text-xs text-gray-500">
{sending ? 'Sending your email...' : 'Ready to send'}
</div>
</div>
);
}

View File

@ -0,0 +1,227 @@
import React, { useState } from 'react';
import { ChevronDown, ChevronUp, Paperclip, X } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
interface ComposeEmailFormProps {
to: string;
setTo: (value: string) => void;
cc: string;
setCc: (value: string) => void;
bcc: string;
setBcc: (value: string) => void;
subject: string;
setSubject: (value: string) => void;
emailContent: string;
setEmailContent: (value: string) => void;
showCc: boolean;
setShowCc: (value: boolean) => void;
showBcc: boolean;
setShowBcc: (value: boolean) => void;
attachments: Array<{
name: string;
content: string;
type: string;
}>;
onAttachmentAdd: (files: FileList) => void;
onAttachmentRemove: (index: number) => void;
}
export default function ComposeEmailForm({
to,
setTo,
cc,
setCc,
bcc,
setBcc,
subject,
setSubject,
emailContent,
setEmailContent,
showCc,
setShowCc,
showBcc,
setShowBcc,
attachments,
onAttachmentAdd,
onAttachmentRemove
}: ComposeEmailFormProps) {
const fileInputRef = React.useRef<HTMLInputElement>(null);
const handleAttachmentClick = () => {
fileInputRef.current?.click();
};
const handleFileSelection = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
onAttachmentAdd(e.target.files);
}
// Reset the input value so the same file can be selected again
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
return (
<div className="p-4 space-y-4">
<div className="space-y-3">
{/* To field */}
<div className="flex items-center">
<Label htmlFor="to" className="w-16 flex-shrink-0">To:</Label>
<Input
id="to"
value={to}
onChange={(e) => setTo(e.target.value)}
placeholder="Email address..."
className="flex-grow"
/>
</div>
{/* CC field - conditionally shown */}
{showCc && (
<div className="flex items-center">
<Label htmlFor="cc" className="w-16 flex-shrink-0">Cc:</Label>
<Input
id="cc"
value={cc}
onChange={(e) => setCc(e.target.value)}
placeholder="CC address..."
className="flex-grow"
/>
</div>
)}
{/* BCC field - conditionally shown */}
{showBcc && (
<div className="flex items-center">
<Label htmlFor="bcc" className="w-16 flex-shrink-0">Bcc:</Label>
<Input
id="bcc"
value={bcc}
onChange={(e) => setBcc(e.target.value)}
placeholder="BCC address..."
className="flex-grow"
/>
</div>
)}
{/* CC/BCC toggle buttons */}
<div className="flex items-center space-x-2 ml-16">
{!showCc && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowCc(true)}
className="h-6 px-2 text-xs text-gray-500 hover:text-gray-700"
>
Add Cc <ChevronDown className="ml-1 h-3 w-3" />
</Button>
)}
{!showBcc && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowBcc(true)}
className="h-6 px-2 text-xs text-gray-500 hover:text-gray-700"
>
Add Bcc <ChevronDown className="ml-1 h-3 w-3" />
</Button>
)}
{showCc && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowCc(false)}
className="h-6 px-2 text-xs text-gray-500 hover:text-gray-700"
>
Remove Cc <ChevronUp className="ml-1 h-3 w-3" />
</Button>
)}
{showBcc && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowBcc(false)}
className="h-6 px-2 text-xs text-gray-500 hover:text-gray-700"
>
Remove Bcc <ChevronUp className="ml-1 h-3 w-3" />
</Button>
)}
</div>
{/* Subject field */}
<div className="flex items-center">
<Label htmlFor="subject" className="w-16 flex-shrink-0">Subject:</Label>
<Input
id="subject"
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="Email subject..."
className="flex-grow"
/>
</div>
</div>
{/* Email content */}
<Textarea
value={emailContent}
onChange={(e) => setEmailContent(e.target.value)}
placeholder="Write your message here..."
className="w-full min-h-[200px]"
/>
{/* Attachments */}
{attachments.length > 0 && (
<div className="border rounded-md p-2">
<h3 className="text-sm font-medium mb-2">Attachments</h3>
<div className="space-y-2">
{attachments.map((file, index) => (
<div key={index} className="flex items-center justify-between text-sm border rounded p-2">
<span className="truncate max-w-[200px]">{file.name}</span>
<Button
variant="ghost"
size="sm"
onClick={() => onAttachmentRemove(index)}
className="h-6 w-6 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
)}
{/* Attachment input (hidden) */}
<input
type="file"
multiple
ref={fileInputRef}
onChange={handleFileSelection}
className="hidden"
/>
{/* Attachments button */}
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAttachmentClick}
className="flex items-center"
>
<Paperclip className="mr-2 h-4 w-4" />
Attach Files
</Button>
</div>
);
}

View File

@ -0,0 +1,41 @@
import React from 'react';
import { X } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface ComposeEmailHeaderProps {
type: 'new' | 'reply' | 'reply-all' | 'forward';
onClose: () => void;
}
export default function ComposeEmailHeader({
type,
onClose
}: ComposeEmailHeaderProps) {
// Set the header title based on the compose type
const getTitle = () => {
switch (type) {
case 'reply':
return 'Reply';
case 'reply-all':
return 'Reply All';
case 'forward':
return 'Forward';
default:
return 'New Message';
}
};
return (
<div className="flex items-center justify-between p-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">{getTitle()}</h2>
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="h-8 w-8"
>
<X className="h-4 w-4" />
</Button>
</div>
);
}

View File

@ -0,0 +1,135 @@
'use client';
import React, { useState } from 'react';
import { Loader2, Paperclip, Download } from 'lucide-react';
import { sanitizeHtml } from '@/lib/utils/dom-purify-config';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
interface EmailAddress {
name: string;
address: string;
}
interface Email {
id: string;
subject: string;
from: EmailAddress[];
to: EmailAddress[];
cc?: EmailAddress[];
bcc?: EmailAddress[];
date: Date | string;
content?: string;
html?: string;
text?: string;
hasAttachments?: boolean;
attachments?: Array<{
filename: string;
contentType: string;
size: number;
path?: string;
content?: string;
}>;
}
interface EmailContentProps {
email: Email;
}
export default function EmailContent({ email }: EmailContentProps) {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Render attachments if they exist
const renderAttachments = () => {
if (!email?.attachments || email.attachments.length === 0) {
return null;
}
return (
<div className="mt-6 border-t border-gray-200 pt-6">
<h3 className="text-sm font-semibold text-gray-900 mb-4">Attachments</h3>
<div className="grid grid-cols-2 gap-4">
{email.attachments.map((attachment, index) => (
<div key={index} className="flex items-center space-x-2 p-2 border rounded hover:bg-gray-50">
<Paperclip className="h-4 w-4 text-gray-400" />
<span className="text-sm text-gray-600 truncate flex-1">
{attachment.filename}
</span>
<Download className="h-4 w-4 text-gray-400 hover:text-gray-600 cursor-pointer" />
</div>
))}
</div>
</div>
);
};
if (isLoading) {
return (
<div className="flex justify-center items-center h-full p-8">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
if (error) {
return (
<Alert variant="destructive" className="m-4">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
);
}
// Format the date for display
const formatDate = (dateObj: Date) => {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const date = new Date(dateObj);
const formattedTime = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
if (date >= today) {
return formattedTime;
} else if (date >= yesterday) {
return `Yesterday, ${formattedTime}`;
} else {
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
};
return (
<div className="email-content-display p-6">
<div className="flex items-center gap-4 mb-6">
<Avatar className="h-10 w-10 bg-gray-100">
<AvatarFallback className="text-gray-600 font-medium">
{email.from?.[0]?.name?.[0] || email.from?.[0]?.address?.[0] || '?'}
</AvatarFallback>
</Avatar>
<div>
<p className="font-medium text-gray-900">
{email.from?.[0]?.name || email.from?.[0]?.address || 'Unknown'}
</p>
<p className="text-sm text-gray-500">
to {email.to?.[0]?.address || 'you'}
</p>
</div>
<div className="ml-auto text-sm text-gray-500">
{formatDate(new Date(email.date))}
</div>
</div>
{email.content ? (
<div className="email-content-display">
<div dangerouslySetInnerHTML={{ __html: sanitizeHtml(email.content) }} />
</div>
) : (
<p className="text-gray-500">No content available</p>
)}
{renderAttachments()}
</div>
);
}

View File

@ -0,0 +1,121 @@
'use client';
import React, { useMemo } from 'react';
import { EmailContent } from '@/types/email';
import { formatEmailContent } from '@/lib/utils/email-content';
interface EmailContentDisplayProps {
content: EmailContent | string | null;
className?: string;
showQuotedText?: boolean;
type?: 'html' | 'text' | 'auto';
debug?: boolean;
}
/**
* Unified component for displaying email content in a consistent way
* This handles both HTML and plain text content with proper styling and RTL support
*/
const EmailContentDisplay: React.FC<EmailContentDisplayProps> = ({
content,
className = '',
showQuotedText = true,
type = 'auto',
debug = false
}) => {
// Process content if provided
const processedContent = useMemo(() => {
if (!content) {
console.log('EmailContentDisplay: No content provided');
return { __html: '<div class="email-content-empty">No content available</div>' };
}
try {
console.log('EmailContentDisplay processing:', {
contentType: typeof content,
isNull: content === null,
isString: typeof content === 'string',
isObject: typeof content === 'object',
length: typeof content === 'string' ? content.length : null
});
let formattedContent: string;
// If it's a string, we need to determine if it's HTML or plain text
if (typeof content === 'string') {
formattedContent = formatEmailContent({ content });
}
// If it's an EmailContent object
else {
formattedContent = formatEmailContent({ content });
}
console.log('EmailContentDisplay processed result length:', formattedContent.length);
return { __html: formattedContent };
} catch (error) {
console.error('Error processing email content:', error);
return { __html: '<div class="email-content-error">Error displaying email content</div>' };
}
}, [content]);
// Handle quoted text display
const displayHTML = useMemo(() => {
if (!showQuotedText) {
// Hide quoted text (usually in blockquotes)
// This is simplified - a more robust approach would parse and handle
// quoted sections more intelligently
const htmlWithoutQuotes = processedContent.__html.replace(/<blockquote[^>]*>[\s\S]*?<\/blockquote>/gi,
'<div class="text-gray-400">[Quoted text hidden]</div>');
return { __html: htmlWithoutQuotes };
}
return processedContent;
}, [processedContent, showQuotedText]);
return (
<div className={`email-content-display ${className}`}>
<div
className="email-content-inner"
dangerouslySetInnerHTML={displayHTML}
/>
{/* Debug output if enabled */}
{debug && (
<div className="mt-4 p-2 text-xs bg-gray-100 border rounded">
<p><strong>Content Type:</strong> {typeof content === 'string' ? 'Text' : 'HTML'}</p>
<p><strong>HTML Length:</strong> {typeof content === 'string' ? content.length : content?.html?.length || 0}</p>
<p><strong>Text Length:</strong> {typeof content === 'string' ? content.length : content?.text?.length || 0}</p>
</div>
)}
<style jsx>{`
.email-content-display {
width: 100%;
}
.email-content-inner img {
max-width: 100%;
height: auto;
}
.email-content-inner blockquote {
margin: 10px 0;
padding-left: 15px;
border-left: 2px solid #ddd;
color: #666;
background-color: #f9f9f9;
border-radius: 4px;
}
/* RTL blockquote styling will be handled by the direction attribute now */
[dir="rtl"] blockquote {
padding-left: 0;
padding-right: 15px;
border-left: none;
border-right: 2px solid #ddd;
}
`}</style>
</div>
);
};
export default EmailContentDisplay;

View File

@ -0,0 +1,233 @@
import React from 'react';
import {
ChevronLeft, Reply, ReplyAll, Forward, Star, MoreHorizontal
} from 'lucide-react';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Email } from '@/hooks/use-courrier';
interface EmailDetailViewProps {
email: Email & {
html?: string;
text?: string;
starred?: boolean; // Add starred property to interface
};
onBack: () => void;
onReply: () => void;
onReplyAll: () => void;
onForward: () => void;
onToggleStar: () => void;
}
export default function EmailDetailView({
email,
onBack,
onReply,
onReplyAll,
onForward,
onToggleStar
}: EmailDetailViewProps) {
// Format date for display
const formatDate = (dateString: string | Date) => {
// Convert to Date object if string
const date = typeof dateString === 'string' ? new Date(dateString) : dateString;
const now = new Date();
if (date.toDateString() === now.toDateString()) {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else {
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
};
// Render email content based on the email body
const renderEmailContent = () => {
try {
console.log('EmailDetailView renderEmailContent', {
hasContent: !!email.content,
contentType: typeof email.content,
hasHtml: !!email.html,
hasText: !!email.text
});
// Determine what content to use and how to handle it
let contentToUse = '';
if (email.content) {
// If content is a string, use it directly
if (typeof email.content === 'string') {
contentToUse = email.content;
}
// If content is an object with html/text properties
else if (typeof email.content === 'object') {
contentToUse = email.content.html || email.content.text || '';
}
}
// Fall back to html or text properties if content is not available
else if (email.html) {
contentToUse = email.html;
}
else if (email.text) {
// Convert plain text to HTML with line breaks
contentToUse = email.text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>');
}
// Return content or fallback message
return contentToUse ?
<div dangerouslySetInnerHTML={{ __html: contentToUse }} /> :
<div className="text-gray-500">No content available</div>;
} catch (e) {
console.error('Error rendering email:', e);
return <div className="text-gray-500">Failed to render email content</div>;
}
};
return (
<div className="flex flex-col h-full overflow-hidden">
{/* Email actions header */}
<div className="flex-none px-4 py-3 border-b border-gray-100">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 min-w-0 flex-1">
<Button
variant="ghost"
size="icon"
onClick={onBack}
className="md:hidden flex-shrink-0"
>
<ChevronLeft className="h-5 w-5" />
</Button>
<div className="min-w-0 max-w-[500px]">
<h2 className="text-lg font-semibold text-gray-900 truncate">
{email.subject}
</h2>
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0 ml-auto">
<div className="flex items-center border-l border-gray-200 pl-4">
<Button
variant="ghost"
size="icon"
className="text-gray-400 hover:text-gray-900 h-9 w-9"
onClick={onReply}
>
<Reply className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-gray-400 hover:text-gray-900 h-9 w-9"
onClick={onReplyAll}
>
<ReplyAll className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-gray-400 hover:text-gray-900 h-9 w-9"
onClick={onForward}
>
<Forward className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-gray-400 hover:text-gray-900 h-9 w-9"
onClick={onToggleStar}
>
<Star className={`h-4 w-4 ${email.starred ? 'fill-yellow-400 text-yellow-400' : ''}`} />
</Button>
</div>
</div>
</div>
</div>
{/* Scrollable content area - enhanced for better scrolling */}
<ScrollArea className="flex-1 overflow-auto">
<div className="p-6">
{/* Email header info */}
<div className="flex items-center gap-4 mb-6">
<Avatar className="h-10 w-10">
<AvatarFallback>
{(email.from?.[0]?.name || '').charAt(0) || (email.from?.[0]?.address || '').charAt(0) || '?'}
</AvatarFallback>
</Avatar>
<div className="flex-1">
<p className="font-medium text-gray-900">
{email.from?.[0]?.name || ''} <span className="text-gray-500">&lt;{email.from?.[0]?.address || ''}&gt;</span>
</p>
<p className="text-sm text-gray-500">
to {email.to?.[0]?.address || ''}
</p>
{email.cc && email.cc.length > 0 && (
<p className="text-sm text-gray-500">
cc {email.cc.map(c => c.address).join(', ')}
</p>
)}
</div>
<div className="text-sm text-gray-500 whitespace-nowrap">
{formatDate(email.date)}
</div>
</div>
{/* Email content with improved scrolling */}
<div className="prose prose-sm max-w-none email-content-wrapper">
{renderEmailContent()}
</div>
{/* Attachments (if any) */}
{email.hasAttachments && email.attachments && email.attachments.length > 0 && (
<div className="mt-6 border-t border-gray-100 pt-4">
<h3 className="text-sm font-medium text-gray-900 mb-2">Attachments</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{email.attachments.map((attachment, idx) => (
<div
key={idx}
className="flex items-center gap-2 p-2 border border-gray-200 rounded-md"
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-700 truncate">{attachment.filename}</p>
<p className="text-xs text-gray-500">{(attachment.size / 1024).toFixed(1)} KB</p>
</div>
</div>
))}
</div>
</div>
)}
</div>
</ScrollArea>
{/* Add CSS for better email content display */}
<style jsx global>{`
.email-content-wrapper {
width: 100%;
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
}
.email-content-wrapper img {
max-width: 100%;
height: auto;
}
.email-content-wrapper table {
max-width: 100%;
overflow-x: auto;
display: block;
}
@media (max-width: 640px) {
.email-content-wrapper {
font-size: 14px;
}
}
`}</style>
</div>
);
}

View File

@ -0,0 +1,74 @@
import React from 'react';
import { AlertCircle } from 'lucide-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
interface DeleteConfirmDialogProps {
show: boolean;
selectedCount: number;
onConfirm: () => Promise<void>;
onCancel: () => void;
}
export function DeleteConfirmDialog({
show,
selectedCount,
onConfirm,
onCancel
}: DeleteConfirmDialogProps) {
return (
<AlertDialog open={show} onOpenChange={(open) => !open && onCancel()}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete {selectedCount} email{selectedCount !== 1 ? 's' : ''}?</AlertDialogTitle>
<AlertDialogDescription>
This will move the selected email{selectedCount !== 1 ? 's' : ''} to the trash folder.
You can restore them later from the trash folder if needed.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={onCancel}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm}>Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
interface LoginNeededAlertProps {
show: boolean;
onLogin: () => void;
onClose: () => void;
}
export function LoginNeededAlert({
show,
onLogin,
onClose
}: LoginNeededAlertProps) {
if (!show) return null;
return (
<Alert className="mb-4">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Please log in to your email account</AlertTitle>
<AlertDescription>
You need to connect your email account before you can access your emails.
</AlertDescription>
<div className="mt-2 flex gap-2">
<Button size="sm" onClick={onLogin}>Go to Login</Button>
<Button size="sm" variant="outline" onClick={onClose}>Dismiss</Button>
</div>
</Alert>
);
}

View File

@ -0,0 +1,119 @@
'use client';
import React, { useState } from 'react';
import { Search, X, Settings, Mail } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from '@/components/ui/tooltip';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
interface EmailHeaderProps {
onSearch: (query: string) => void;
onSettingsClick?: () => void;
}
export default function EmailHeader({
onSearch,
onSettingsClick,
}: EmailHeaderProps) {
const [searchQuery, setSearchQuery] = useState('');
const [isSearching, setIsSearching] = useState(false);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
onSearch(searchQuery);
};
const clearSearch = () => {
setSearchQuery('');
onSearch('');
};
return (
<div className="border-b bg-white/95 backdrop-blur-sm flex flex-col">
{/* Courrier Title with improved styling */}
<div className="p-3 border-b border-gray-100">
<div className="flex items-center gap-2">
<Mail className="h-6 w-6 text-blue-600" />
<span className="text-xl font-semibold text-gray-900">COURRIER</span>
</div>
</div>
<div className="px-4 py-2 flex items-center">
<div className="flex-1">
<form onSubmit={handleSearch} className="relative">
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
type="text"
placeholder="Search emails..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8 pr-8 h-9 bg-gray-50 border-gray-200 focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50"
/>
{searchQuery && (
<button
type="button"
onClick={clearSearch}
className="absolute right-2 top-1/2 transform -translate-y-1/2"
>
<X className="h-4 w-4 text-gray-400 hover:text-gray-600" />
</button>
)}
</form>
</div>
<div className="ml-2 flex items-center gap-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="submit"
size="icon"
variant="ghost"
className="h-8 w-8 text-gray-600 hover:text-gray-900"
onClick={handleSearch}
>
<Search className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Search</TooltipContent>
</Tooltip>
</TooltipProvider>
<DropdownMenu>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 text-gray-600 hover:text-gray-900">
<Settings className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>Settings</TooltipContent>
</Tooltip>
</TooltipProvider>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onSettingsClick}>
Email settings
</DropdownMenuItem>
<DropdownMenuItem onClick={onSettingsClick}>
Configure IMAP
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More