Skip to content

Commit 85f80ea

Browse files
sh1pt-actions-fleet[bot]ralyodioclaude
authored
dev2 webmail: Google Calendar (OAuth + CalDAV, full CRUD); import range default All (#232)
- webmail/googlecal plugin: Settings > Google Calendar > Connect (OAuth 2, scope calendar, offline); refresh token in user prefs encrypted with des_key; access token per session; calendar driver -> caldav at apidata.googleusercontent.com. 'Use Google as my calendar' switch keeps the default backend without disconnecting. - Dockerfile: libkolab DAV client sends Bearer for hosts in dav_oauth_tokens (Google only; FE keeps basic auth); calendar import Range defaulted to '1 month back' and silently dropped older events -> default All. Image profullstack/webmail:1.7.5-cal2. Co-authored-by: Anthony Ettinger <anthony@profullstack.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent d9c8949 commit 85f80ea

3 files changed

Lines changed: 268 additions & 2 deletions

File tree

‎dev2/sites.d/webmail.profullstack.com.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"service": "webmail.profullstack.com",
44
"repo": null,
55
"branch": null,
6-
"image": "profullstack/webmail:1.7.5-cal1",
6+
"image": "profullstack/webmail:1.7.5-cal2",
77
"start_command": "apache2-foreground",
88
"port_var": "80",
99
"domains": [
@@ -48,7 +48,7 @@
4848
"ROUNDCUBEMAIL_DEFAULT_PORT": "993",
4949
"ROUNDCUBEMAIL_SMTP_SERVER": "ssl://smtp.forwardemail.net",
5050
"ROUNDCUBEMAIL_SMTP_PORT": "465",
51-
"ROUNDCUBEMAIL_PLUGINS": "anyhost,archive,zipdownload,libcalendaring,libkolab,calendar,tasklist,carddav",
51+
"ROUNDCUBEMAIL_PLUGINS": "anyhost,googlecal,archive,zipdownload,libcalendaring,libkolab,calendar,tasklist,carddav",
5252
"ROUNDCUBEMAIL_SKIN": "elastic",
5353
"ROUNDCUBEMAIL_UPLOAD_MAX_FILESIZE": "25M"
5454
},

‎dev2/webmail/Dockerfile‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,18 @@ RUN sed -i 's/^CREATE INDEX ix_contact_type ON kolab_cache_dav_contact/CREATE IN
2121
/usr/src/roundcubemail/plugins/libkolab/SQL/sqlite.initial.sql \
2222
&& grep -c ix_dav_contact_type /usr/src/roundcubemail/plugins/libkolab/SQL/sqlite.initial.sql
2323

24+
# googlecal: libkolab's DAV client only does basic auth. Send `Bearer <token>` instead
25+
# when dav_oauth_tokens has one for the request's host (Google CalDAV); every other
26+
# server (Forward Email) keeps username/password.
27+
RUN f=/usr/src/roundcubemail/plugins/libkolab/lib/kolab_dav_client.php \
28+
&& sed -i 's#^ \$request->setAuth(\$this->user, \$this->password);# $oauth = (array) $rcube->config->get("dav_oauth_tokens", []); $host = parse_url($this->url, PHP_URL_HOST);\n if (!empty($oauth[$host])) { $request->setHeader("Authorization", "Bearer " . $oauth[$host]); } else { $request->setAuth($this->user, $this->password); }#' $f \
29+
&& grep -c 'dav_oauth_tokens' $f
30+
31+
# The import dialog's Range defaulted to "1 month back", silently skipping every older
32+
# event (and recurring series that started earlier). Default to All.
33+
RUN f=/usr/src/roundcubemail/plugins/calendar/lib/calendar_ui.php \
34+
&& sed -i "s#html::div('col-sm-8', \$select->show(1))#html::div('col-sm-8', \$select->show(0))#" $f \
35+
&& grep -c "select->show(0))" $f
36+
37+
COPY --chmod=755 googlecal /usr/src/roundcubemail/plugins/googlecal
2438
COPY --chmod=644 carddav.config.inc.php /usr/src/roundcubemail/plugins/carddav/config.inc.php
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
<?php
2+
3+
/**
4+
* googlecal: connect a Google account so the Calendar tab reads and writes (create,
5+
* edit, delete) Google Calendar over CalDAV with OAuth 2.
6+
*
7+
* Settings > Preferences > Google Calendar: Connect / Disconnect, and a switch to keep
8+
* using the default calendar (Forward Email or local) while connected. When connected
9+
* and on, the calendar driver is caldav against apidata.googleusercontent.com and
10+
* libkolab's DAV client sends `Authorization: Bearer <access token>` to that host only
11+
* (patched in the Dockerfile). Tasks stay on the default backend: Google CalDAV has no
12+
* VTODO.
13+
*
14+
* The refresh token is stored in the user's prefs, encrypted with des_key. Access
15+
* tokens live in the session and are refreshed a minute before they expire.
16+
*
17+
* Config (zz-profullstack.inc.php): googlecal_client_id, googlecal_client_secret,
18+
* googlecal_redirect_uri (must match the OAuth client exactly).
19+
*
20+
* Source: profullstack/cli-tools dev2/webmail/googlecal.
21+
*/
22+
class googlecal extends rcube_plugin
23+
{
24+
public $task = 'mail|settings|calendar|tasks|addressbook|utils';
25+
26+
const AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
27+
const TOKEN_URL = 'https://oauth2.googleapis.com/token';
28+
const REVOKE_URL = 'https://oauth2.googleapis.com/revoke';
29+
const SCOPE = 'openid email https://www.googleapis.com/auth/calendar';
30+
const DAV = 'https://apidata.googleusercontent.com/caldav/v2';
31+
const DAV_HOST = 'apidata.googleusercontent.com';
32+
33+
private $rc;
34+
35+
public function init()
36+
{
37+
$this->rc = rcmail::get_instance();
38+
$this->add_hook('startup', [$this, 'startup']);
39+
$this->register_action('plugin.googlecal-connect', [$this, 'connect']);
40+
$this->register_action('plugin.googlecal-callback', [$this, 'callback']);
41+
$this->register_action('plugin.googlecal-disconnect', [$this, 'disconnect']);
42+
$this->add_hook('preferences_sections_list', [$this, 'sections']);
43+
$this->add_hook('preferences_list', [$this, 'prefs']);
44+
$this->add_hook('preferences_save', [$this, 'save']);
45+
}
46+
47+
/** Runs after anyhost's startup hook (plugin order), before calendar's. */
48+
public function startup($args)
49+
{
50+
$st = $this->state();
51+
if (!$st || !empty($st['off']) || !$this->configured()) {
52+
return $args;
53+
}
54+
$token = $this->access_token($st);
55+
if (!$token) {
56+
return $args;
57+
}
58+
$config = $this->rc->config;
59+
$config->set('calendar_driver', 'caldav');
60+
$config->set('calendar_caldav_server', self::DAV);
61+
$tokens = (array) $config->get('dav_oauth_tokens', []);
62+
$tokens[self::DAV_HOST] = $token;
63+
$config->set('dav_oauth_tokens', $tokens);
64+
return $args;
65+
}
66+
67+
public function connect()
68+
{
69+
if (!$this->configured()) {
70+
$this->rc->output->show_message('Google Calendar is not set up on this server yet.', 'error');
71+
$this->rc->output->redirect(['_task' => 'settings', '_action' => 'edit-prefs', '_section' => 'googlecal']);
72+
return;
73+
}
74+
$_SESSION['googlecal_state'] = $state = bin2hex(random_bytes(16));
75+
$url = self::AUTH_URL . '?' . http_build_query([
76+
'client_id' => $this->rc->config->get('googlecal_client_id'),
77+
'redirect_uri' => $this->redirect_uri(),
78+
'response_type' => 'code',
79+
'scope' => self::SCOPE,
80+
'access_type' => 'offline',
81+
'prompt' => 'consent',
82+
'include_granted_scopes' => 'true',
83+
'state' => $state,
84+
]);
85+
header('Location: ' . $url);
86+
exit;
87+
}
88+
89+
public function callback()
90+
{
91+
$back = ['_task' => 'settings', '_action' => 'preferences', '_section' => 'googlecal'];
92+
$state = rcube_utils::get_input_string('state', rcube_utils::INPUT_GET);
93+
$expected = $_SESSION['googlecal_state'] ?? '';
94+
unset($_SESSION['googlecal_state']);
95+
if ($expected === '' || !hash_equals($expected, (string) $state)) {
96+
$this->rc->output->show_message('Google sign-in expired or was tampered with; try Connect again.', 'error');
97+
$this->rc->output->redirect($back);
98+
return;
99+
}
100+
$code = rcube_utils::get_input_string('code', rcube_utils::INPUT_GET);
101+
if (!$code) {
102+
$err = rcube_utils::get_input_string('error', rcube_utils::INPUT_GET);
103+
$this->rc->output->show_message('Google did not connect' . ($err ? " ($err)" : '') . '.', 'warning');
104+
$this->rc->output->redirect($back);
105+
return;
106+
}
107+
$r = $this->post(self::TOKEN_URL, [
108+
'code' => $code,
109+
'client_id' => $this->rc->config->get('googlecal_client_id'),
110+
'client_secret' => $this->rc->config->get('googlecal_client_secret'),
111+
'redirect_uri' => $this->redirect_uri(),
112+
'grant_type' => 'authorization_code',
113+
]);
114+
if (empty($r['refresh_token']) || empty($r['access_token'])) {
115+
rcube::raise_error(['code' => 500, 'message' => 'googlecal: token exchange failed: ' . ($r['error'] ?? 'no refresh_token')], true, false);
116+
$this->rc->output->show_message('Google sign-in failed; try Connect again.', 'error');
117+
$this->rc->output->redirect($back);
118+
return;
119+
}
120+
$email = '';
121+
if (!empty($r['id_token']) && count($parts = explode('.', $r['id_token'])) === 3) {
122+
$claims = json_decode(base64_decode(strtr($parts[1], '-_', '+/')), true);
123+
$email = (string) ($claims['email'] ?? '');
124+
}
125+
$this->save_state(['refresh' => $r['refresh_token'], 'email' => $email, 'off' => false]);
126+
$_SESSION['googlecal_at'] = $r['access_token'];
127+
$_SESSION['googlecal_exp'] = time() + (int) ($r['expires_in'] ?? 3600);
128+
$this->rc->output->show_message('Google Calendar connected' . ($email ? " ($email)" : '') . '.', 'confirmation');
129+
$this->rc->output->redirect(['_task' => 'calendar']);
130+
}
131+
132+
public function disconnect()
133+
{
134+
if (!$this->rc->check_request(rcube_utils::INPUT_GET)) {
135+
$this->rc->output->show_message('Request check failed; reload Settings and try again.', 'error');
136+
} elseif ($st = $this->state()) {
137+
$this->post(self::REVOKE_URL, ['token' => $st['refresh']]);
138+
$this->save_state(null);
139+
unset($_SESSION['googlecal_at'], $_SESSION['googlecal_exp']);
140+
$this->rc->output->show_message('Google Calendar disconnected.', 'confirmation');
141+
}
142+
$this->rc->output->redirect(['_task' => 'settings', '_action' => 'preferences', '_section' => 'googlecal']);
143+
}
144+
145+
public function sections($args)
146+
{
147+
$args['list']['googlecal'] = ['id' => 'googlecal', 'section' => 'Google Calendar'];
148+
return $args;
149+
}
150+
151+
public function prefs($args)
152+
{
153+
if ($args['section'] !== 'googlecal') {
154+
return $args;
155+
}
156+
$st = $this->state();
157+
$blocks = ['main' => ['name' => 'Google Calendar', 'options' => []]];
158+
if (!$this->configured()) {
159+
$blocks['main']['options']['status'] = ['title' => 'Status', 'content' => 'Not available yet: the server has no Google OAuth client configured.'];
160+
} elseif (!$st) {
161+
$link = $this->rc->url(['_task' => 'settings', '_action' => 'plugin.googlecal-connect']);
162+
$blocks['main']['options']['status'] = ['title' => 'Status', 'content' => 'Not connected'];
163+
$blocks['main']['options']['connect'] = ['title' => '', 'content' =>
164+
html::a(['href' => $link, 'class' => 'button mainaction', 'target' => '_top'], 'Connect Google Calendar')
165+
. html::p('hint', 'Your Calendar tab will show and edit your Google calendars. Tasks stay where they are.')];
166+
} else {
167+
$link = $this->rc->url(['_task' => 'settings', '_action' => 'plugin.googlecal-disconnect', '_token' => $this->rc->get_request_token()]);
168+
$use = new html_checkbox(['name' => '_googlecal_use', 'id' => 'googlecal_use', 'value' => 1]);
169+
$blocks['main']['options']['status'] = ['title' => 'Status', 'content' => rcube::Q('Connected' . (!empty($st['email']) ? ' as ' . $st['email'] : ''))];
170+
$blocks['main']['options']['use'] = ['title' => html::label('googlecal_use', 'Use Google as my calendar'),
171+
'content' => $use->show(empty($st['off']) ? 1 : 0) . html::p('hint', 'Untick to go back to your default calendar without disconnecting.')];
172+
$blocks['main']['options']['disconnect'] = ['title' => '', 'content' =>
173+
html::a(['href' => $link, 'class' => 'button', 'target' => '_top'], 'Disconnect Google')];
174+
}
175+
$args['blocks'] = $blocks;
176+
return $args;
177+
}
178+
179+
public function save($args)
180+
{
181+
if ($args['section'] === 'googlecal' && ($st = $this->state())) {
182+
$st['off'] = empty($_POST['_googlecal_use']);
183+
$this->save_state($st);
184+
}
185+
return $args;
186+
}
187+
188+
private function configured()
189+
{
190+
return $this->rc->config->get('googlecal_client_id') && $this->rc->config->get('googlecal_client_secret');
191+
}
192+
193+
private function redirect_uri()
194+
{
195+
return $this->rc->config->get('googlecal_redirect_uri',
196+
'https://webmail.profullstack.com/?_task=settings&_action=plugin.googlecal-callback');
197+
}
198+
199+
private function state()
200+
{
201+
$raw = $this->rc->config->get('googlecal_state');
202+
if (!$raw) {
203+
return null;
204+
}
205+
$st = json_decode((string) $this->rc->decrypt($raw), true);
206+
return is_array($st) && !empty($st['refresh']) ? $st : null;
207+
}
208+
209+
private function save_state($st)
210+
{
211+
$value = $st ? $this->rc->encrypt(json_encode($st)) : null;
212+
$this->rc->user->save_prefs(['googlecal_state' => $value]);
213+
$this->rc->config->set('googlecal_state', $value);
214+
}
215+
216+
private function access_token($st)
217+
{
218+
if (!empty($_SESSION['googlecal_at']) && ($_SESSION['googlecal_exp'] ?? 0) > time() + 60) {
219+
return $_SESSION['googlecal_at'];
220+
}
221+
$r = $this->post(self::TOKEN_URL, [
222+
'client_id' => $this->rc->config->get('googlecal_client_id'),
223+
'client_secret' => $this->rc->config->get('googlecal_client_secret'),
224+
'refresh_token' => $st['refresh'],
225+
'grant_type' => 'refresh_token',
226+
]);
227+
if (empty($r['access_token'])) {
228+
rcube::raise_error(['code' => 500, 'message' => 'googlecal: refresh failed: ' . ($r['error'] ?? 'unknown')], true, false);
229+
if (($r['error'] ?? '') === 'invalid_grant') {
230+
// Revoked at Google or expired: forget it so the user sees "Not connected".
231+
$this->save_state(null);
232+
}
233+
return null;
234+
}
235+
$_SESSION['googlecal_at'] = $r['access_token'];
236+
$_SESSION['googlecal_exp'] = time() + (int) ($r['expires_in'] ?? 3600);
237+
return $r['access_token'];
238+
}
239+
240+
private function post($url, $fields)
241+
{
242+
$ctx = stream_context_create(['http' => [
243+
'method' => 'POST',
244+
'header' => "Content-Type: application/x-www-form-urlencoded\r\nAccept: application/json\r\n",
245+
'content' => http_build_query($fields),
246+
'timeout' => 15,
247+
'ignore_errors' => true,
248+
]]);
249+
$body = @file_get_contents($url, false, $ctx);
250+
return $body === false ? [] : (array) json_decode($body, true);
251+
}
252+
}

0 commit comments

Comments
 (0)