From 1f33296e30fa80c572a275f255455fe5eae92a76 Mon Sep 17 00:00:00 2001 From: Carlos Arias Date: Sat, 11 Jul 2026 09:14:25 -0500 Subject: [PATCH] feat: user auth (email + Google OAuth), Mailer, and CLI bridges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generic framework features, code-only — the tables come from the schema snapshot: - controllers/account.php — email signup (verified) + Google OAuth (server-side auth-code flow, async-popup friendly); native password_hash sessions on sp_users; sp_oauth_accounts + sp_user_tokens; PublicController origin+throttle guards; {ok,data,error} envelope. - Helpers/Mailer.php — transactional email: Brevo HTTP API (Guzzle) -> SMTP fallback (PHPMailer) -> logs; config in sp_settings (encrypted). - cli/rebuild.php — queue a static rebuild (sp_settings rebuild_pending flag) after DB edits. - cli/resources.php — read a curated source registry (mde_resources) per agent_type. - db/seed_google_oauth.php, db/seed_email.php — env-seeded encrypted secrets into sp_settings. - templates/emails/verify.html — verification email template. - composer.json — declare guzzlehttp/guzzle ^7.10 (account.php + Mailer use GuzzleHttp\Client). Depends on the schema-snapshot branch (sp_users / sp_oauth_accounts / sp_user_tokens / sp_settings / mde_resources) plus `composer require guzzlehttp/guzzle`. No data. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01FMQeUnUrAeexcZ7P2Hxa6G --- api/app/Helpers/Mailer.php | 100 ++++++++ api/cli/rebuild.php | 20 ++ api/cli/resources.php | 38 +++ api/composer.json | 1 + api/db/seed_email.php | 38 +++ api/db/seed_google_oauth.php | 49 ++++ api/public/controllers/account.php | 374 +++++++++++++++++++++++++++++ api/templates/emails/verify.html | 34 +++ 8 files changed, 654 insertions(+) create mode 100644 api/app/Helpers/Mailer.php create mode 100644 api/cli/rebuild.php create mode 100644 api/cli/resources.php create mode 100644 api/db/seed_email.php create mode 100644 api/db/seed_google_oauth.php create mode 100644 api/public/controllers/account.php create mode 100644 api/templates/emails/verify.html diff --git a/api/app/Helpers/Mailer.php b/api/app/Helpers/Mailer.php new file mode 100644 index 0000000..acee919 --- /dev/null +++ b/api/app/Helpers/Mailer.php @@ -0,0 +1,100 @@ +, "from_email":"...", "from_name":"..." } + * Seed it with db/seed_email.php (reads BREVO_API_KEY from the environment). + * + * Send order: Brevo HTTP API (if configured) → SMTP via config.php EMAIL* constants + * (if set) → false. Callers treat false as "not sent" and log a fallback (e.g. the + * account verification link) so flows stay testable before email is wired up. + */ +class Mailer +{ + /** @return bool true when the provider accepted the message. */ + public static function send(string $toEmail, string $toName, string $subject, string $html, string $text = ''): bool + { + $cfg = json_decode((string) \Db::getValue( + "SELECT metval FROM sp_settings WHERE keyval = 'email' AND status = 1 LIMIT 1" + ), true) ?: []; + + if (strtolower((string) ($cfg['provider'] ?? '')) === 'brevo' && !empty($cfg['api_key'])) { + return self::sendBrevo($cfg, $toEmail, $toName, $subject, $html, $text); + } + + $host = defined('EMAILHOST') ? (string) EMAILHOST : ''; + if ($host !== '' && !str_starts_with($host, 'ADD')) { + return self::sendSmtp($toEmail, $toName, $subject, $html, $text); + } + + return false; // unconfigured + } + + private static function fromEmail(array $cfg): string + { + $from = trim((string) ($cfg['from_email'] ?? '')); + return $from !== '' ? $from : 'noreply@' . (parse_url(URL, PHP_URL_HOST) ?: 'localhost'); + } + + private static function fromName(array $cfg): string + { + $name = trim((string) ($cfg['from_name'] ?? '')); + return $name !== '' ? $name : (defined('PROJECT_NAME') ? PROJECT_NAME : 'Website'); + } + + private static function sendBrevo(array $cfg, string $toEmail, string $toName, string $subject, string $html, string $text): bool + { + try { + $http = new \GuzzleHttp\Client(['timeout' => 12, 'http_errors' => false]); + $res = $http->post('https://api.brevo.com/v3/smtp/email', [ + 'headers' => [ + 'api-key' => \Functions::Decrypt((string) $cfg['api_key']), + 'accept' => 'application/json', + 'content-type' => 'application/json', + ], + 'json' => [ + 'sender' => ['name' => self::fromName($cfg), 'email' => self::fromEmail($cfg)], + 'to' => [['email' => $toEmail, 'name' => $toName !== '' ? $toName : $toEmail]], + 'subject' => $subject, + 'htmlContent' => $html, + 'textContent' => $text !== '' ? $text : trim(strip_tags($html)), + ], + ]); + $code = $res->getStatusCode(); + if ($code >= 200 && $code < 300) { + return true; + } + error_log('[mailer] brevo HTTP ' . $code . ': ' . (string) $res->getBody()); + return false; + } catch (\Throwable $e) { + error_log('[mailer] brevo exception: ' . $e->getMessage()); + return false; + } + } + + private static function sendSmtp(string $toEmail, string $toName, string $subject, string $html, string $text): bool + { + try { + $mail = new \PHPMailer\PHPMailer\PHPMailer(true); + $mail->isSMTP(); + $mail->Host = EMAILHOST; + $mail->SMTPAuth = true; + $mail->Username = defined('EMAILUSER') ? EMAILUSER : ''; + $mail->Password = defined('EMAILPASSWORD') ? EMAILPASSWORD : ''; + $mail->SMTPSecure = 'tls'; + $mail->Port = 587; + $mail->setFrom('noreply@' . (parse_url(URL, PHP_URL_HOST) ?: 'localhost'), defined('PROJECT_NAME') ? PROJECT_NAME : 'Website'); + $mail->addAddress($toEmail, $toName !== '' ? $toName : $toEmail); + $mail->isHTML(true); + $mail->Subject = $subject; + $mail->Body = $html; + $mail->AltBody = $text !== '' ? $text : trim(strip_tags($html)); + $mail->send(); + return true; + } catch (\Throwable $e) { + error_log('[mailer] smtp send failed: ' . $e->getMessage()); + return false; + } + } +} diff --git a/api/cli/rebuild.php b/api/cli/rebuild.php new file mode 100644 index 0000000..770805b --- /dev/null +++ b/api/cli/rebuild.php @@ -0,0 +1,20 @@ + date('c'), 'by' => 'cli'])] +); + +echo "Rebuild queued — deploy-watch will build within ~3 min.\n"; diff --git a/api/cli/resources.php b/api/cli/resources.php new file mode 100644 index 0000000..f28192a --- /dev/null +++ b/api/cli/resources.php @@ -0,0 +1,38 @@ + -> JSON array of active resources + * php api/cli/resources.php touch [...] -> stamp last_used_at + */ +require __DIR__ . '/../vendor/autoload.php'; +require __DIR__ . '/../config.php'; + +$cmd = $argv[1] ?? ''; + +if ($cmd === 'list') { + $type = $argv[2] ?? ''; + if ($type === '') { fwrite(STDERR, "usage: resources.php list \n"); exit(1); } + $rows = \Db::select( + "SELECT resource_id, name, url, kind, notes, priority + FROM mde_resources + WHERE agent_type = ? AND active = 1 + ORDER BY priority DESC, resource_id ASC", + [$type] + ); + echo json_encode($rows, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + exit; +} + +if ($cmd === 'touch') { + $ids = array_slice($argv, 2); + foreach ($ids as $id) { + \Db::execute("UPDATE mde_resources SET last_used_at = NOW() WHERE resource_id = ?", [(int) $id]); + } + echo json_encode(['ok' => true, 'touched' => count($ids)]); + exit; +} + +fwrite(STDERR, "usage: resources.php list | touch \n"); +exit(1); diff --git a/api/composer.json b/api/composer.json index 0778a06..64ae9c2 100644 --- a/api/composer.json +++ b/api/composer.json @@ -4,6 +4,7 @@ "anthropic-ai/sdk": "^0.3.0", "carbonphp/carbon-doctrine-types": "^2.1.0", "getbrevo/brevo-php": "^1.0.2", + "guzzlehttp/guzzle": "^7.10", "guzzlehttp/promises": "^2.3.0", "guzzlehttp/psr7": "^2.8.0", "mashape/unirest-php": "^3.0.4", diff --git a/api/db/seed_email.php b/api/db/seed_email.php new file mode 100644 index 0000000..8ad68f8 --- /dev/null +++ b/api/db/seed_email.php @@ -0,0 +1,38 @@ + 'brevo', + 'api_key' => \Functions::Encrypt($apiKey), + 'from_email' => $fromEmail, + 'from_name' => $fromName, +]; + +\Db::execute("DELETE FROM sp_settings WHERE keyval = ?", ['email']); +\Db::execute( + "INSERT INTO sp_settings (keyval, `group`, metval, status, encrypted, createdate) VALUES ('email', 'email', ?, 1, 1, ?)", + [json_encode($meta), date('Y-m-d H:i:s')] +); + +echo "saved email (provider=brevo, from={$fromName} <{$fromEmail}>, encrypted=1)\n"; diff --git a/api/db/seed_google_oauth.php b/api/db/seed_google_oauth.php new file mode 100644 index 0000000..69bcc4d --- /dev/null +++ b/api/db/seed_google_oauth.php @@ -0,0 +1,49 @@ + 'google', + 'client_id' => $clientId, + 'client_secret' => \Functions::Encrypt($clientSecret), + 'redirect_uri' => $redirect, + 'auth_uri' => 'https://accounts.google.com/o/oauth2/v2/auth', + 'token_uri' => 'https://oauth2.googleapis.com/token', + 'userinfo_uri' => 'https://www.googleapis.com/oauth2/v3/userinfo', + 'scope' => 'openid email profile', +]; + +\Db::execute("DELETE FROM sp_settings WHERE keyval = ?", ['google_oauth']); +\Db::execute( + "INSERT INTO sp_settings (keyval, `group`, metval, status, encrypted, createdate) VALUES (?, 'auth', ?, 1, 1, ?)", + ['google_oauth', json_encode($meta), date('Y-m-d H:i:s')] +); + +echo "saved google_oauth (group=auth, encrypted=1)\n"; +echo "redirect_uri = {$redirect}\n"; diff --git a/api/public/controllers/account.php b/api/public/controllers/account.php new file mode 100644 index 0000000..a59d7a5 --- /dev/null +++ b/api/public/controllers/account.php @@ -0,0 +1,374 @@ +_url[0](...)`, so the controller class name must be unique. + * + * Session: on login we set $_SESSION['login'] = { userid, email, name, avatar } and + * regenerate the session id. Community members are plain logged-in users — they do NOT + * use the sp_role_perm admin permission grid. + * + * Routes (all under /api/account): + * POST /register { name, email, password } -> creates pending user + emails verify link + * GET /verify?token=.. email link -> flips email_verified, redirects to site + * POST /login { email, password } -> sets session, returns user + * POST /logout -> clears session + * GET /me -> current user or null + * GET /google[?code&state] -> leg 1 redirects to Google, leg 2 handles callback + */ +class Account extends PublicController +{ + // ── request/response helpers ───────────────────────────────────────────── + + private function input(): array + { + return json_decode((string) file_get_contents('php://input'), true) ?: []; + } + + private function currentUser(): ?array + { + $u = $_SESSION['login'] ?? null; + return (is_array($u) && !empty($u['userid'])) ? $u : null; + } + + /** Shape a sp_users row into the safe object the frontend consumes. */ + private function publicUser(array $row): array + { + $clean = fn($v) => ($v === '0' || $v === null) ? '' : trim((string) $v); + $name = trim($clean($row['fname'] ?? '') . ' ' . $clean($row['lname'] ?? '')); + if ($name === '') { + $name = $clean($row['username'] ?? '') ?: (string) ($row['email'] ?? ''); + } + $avatar = $clean($row['avatar'] ?? ''); + if ($avatar === '') { + $avatar = \Functions::getAvatar((string) ($row['email'] ?? '')); + } + return [ + 'userid' => (int) $row['userid'], + 'email' => (string) ($row['email'] ?? ''), + 'name' => $name, + 'avatar' => $avatar, + ]; + } + + private function startSession(array $row): void + { + session_regenerate_id(true); + $_SESSION['login'] = $this->publicUser($row); + } + + private function splitName(string $name): array + { + $parts = preg_split('/\s+/', trim($name), 2) ?: []; + return [$parts[0] ?? $name, $parts[1] ?? '']; + } + + // ── actions ────────────────────────────────────────────────────────────── + + /** GET /api/account -> alias for /me */ + public function index() + { + $this->me(); + } + + /** POST /api/account/register */ + public function register() + { + $this->guardPublic('account.register', 10, 600); // 10 / 10 min / IP + + $b = $this->input(); + $name = trim((string) ($b['name'] ?? '')); + $email = strtolower(trim((string) ($b['email'] ?? ''))); + $password = (string) ($b['password'] ?? ''); + + if ($name === '') { + $this->json(null, 422, ['code' => 'name_required', 'message' => 'Please enter your name.']); + } + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + $this->json(null, 422, ['code' => 'invalid_email', 'message' => 'Enter a valid email address.']); + } + if (strlen($password) < 8) { + $this->json(null, 422, ['code' => 'weak_password', 'message' => 'Password must be at least 8 characters.']); + } + if (\Functions::CheckUserExist($email)) { + $this->json(null, 409, ['code' => 'email_taken', 'message' => 'An account with this email already exists.']); + } + + [$fname, $lname] = $this->splitName($name); + + $res = json_decode(\Db::insert('sp_users', [ + 'username' => $email, + 'email' => $email, + 'email_verified' => 0, + 'password' => password_hash($password, PASSWORD_DEFAULT), + 'fname' => $fname, + 'lname' => $lname, + 'role_id' => null, // community member — no admin role (FK -> sp_roles allows NULL) + 'active' => 1, + ]), true); + if (($res['Code'] ?? 0) != 1) { + $this->json(null, 500, ['code' => 'db_error', 'message' => 'Could not create the account.']); + } + $userid = (int) $res['ID']; + + $token = bin2hex(random_bytes(24)); // 48 chars + \Db::execute( + "INSERT INTO sp_user_tokens (userid, token, type, expires_at, created_at) VALUES (?, ?, 'verify', ?, NOW())", + [$userid, $token, date('Y-m-d H:i:s', time() + 86400)] + ); + + $this->sendVerifyEmail($email, $name, $token); + + $this->json(['status' => 'pending_verification', 'email' => $email]); + } + + /** GET /api/account/verify?token=... — clicked from the email; redirects back to the site. */ + public function verify() + { + $this->guardPublic('account.verify', 30, 600); + + $token = (string) ($_GET['token'] ?? ''); + $row = $token !== '' ? \Db::getRow( + "SELECT id, userid FROM sp_user_tokens + WHERE token = ? AND type = 'verify' AND used_at IS NULL AND expires_at > NOW() LIMIT 1", + [$token] + ) : null; + + if (!$row) { + $this->redirect('/?verified=0'); + } + + \Db::execute("UPDATE sp_users SET email_verified = 1 WHERE userid = ?", [$row['userid']]); + \Db::execute("UPDATE sp_user_tokens SET used_at = NOW() WHERE id = ?", [$row['id']]); + $this->redirect('/?verified=1'); + } + + /** POST /api/account/login */ + public function login() + { + $this->guardPublic('account.login', 20, 600); + + $b = $this->input(); + $email = strtolower(trim((string) ($b['email'] ?? ''))); + $password = (string) ($b['password'] ?? ''); + + $row = \Db::getRow("SELECT * FROM sp_users WHERE email = ? LIMIT 1", [$email]); + if (!$row || empty($row['password']) || !password_verify($password, $row['password'])) { + $this->json(null, 401, ['code' => 'bad_credentials', 'message' => 'Incorrect email or password.']); + } + if ((int) ($row['active'] ?? 0) !== 1) { + $this->json(null, 403, ['code' => 'account_disabled', 'message' => 'This account is disabled.']); + } + if ((int) ($row['email_verified'] ?? 0) !== 1) { + $this->json(null, 403, ['code' => 'unverified', 'message' => 'Please verify your email before signing in.']); + } + + $this->startSession($row); + $this->json(['user' => $this->publicUser($row)]); + } + + /** POST /api/account/logout */ + public function logout() + { + $this->guardPublic('account.logout', 60, 60); + unset($_SESSION['login']); + session_regenerate_id(true); + $this->json(['status' => 'logged_out']); + } + + /** GET /api/account/me */ + public function me() + { + $this->guardPublic('account.me', 600, 60); + $this->json(['user' => $this->currentUser()]); + } + + /** GET /api/account/google — leg 1 (redirect to consent) and leg 2 (callback), by ?code presence. */ + public function google() + { + $cfg = $this->googleConfig(); + if (!$cfg) { + $this->json(null, 500, ['code' => 'oauth_unconfigured', 'message' => 'Google sign-in is not configured.']); + } + + // ── Leg 1: no code → send the user to Google's consent screen ── + if (($_GET['code'] ?? '') === '') { + $state = bin2hex(random_bytes(16)); + $_SESSION['oauth_state'] = $state; + $_SESSION['oauth_popup'] = (($_GET['mode'] ?? '') === 'popup'); // async popup vs full redirect + $q = http_build_query([ + 'client_id' => $cfg['client_id'], + 'redirect_uri' => $cfg['redirect_uri'], + 'response_type' => 'code', + 'scope' => $cfg['scope'], + 'state' => $state, + 'access_type' => 'online', + 'prompt' => 'select_account', + ]); + header('Location: ' . $cfg['auth_uri'] . '?' . $q); + exit; + } + + // ── Leg 2: callback — verify state (CSRF), exchange code, fetch profile ── + $state = (string) ($_GET['state'] ?? ''); + if ($state === '' || !hash_equals((string) ($_SESSION['oauth_state'] ?? ''), $state)) { + $this->finishOauth(false); + } + unset($_SESSION['oauth_state']); + + try { + $http = new \GuzzleHttp\Client(['timeout' => 10, 'http_errors' => false]); + + $tok = json_decode((string) $http->post($cfg['token_uri'], ['form_params' => [ + 'code' => (string) $_GET['code'], + 'client_id' => $cfg['client_id'], + 'client_secret' => $cfg['client_secret'], + 'redirect_uri' => $cfg['redirect_uri'], + 'grant_type' => 'authorization_code', + ]])->getBody(), true) ?: []; + + $access = (string) ($tok['access_token'] ?? ''); + if ($access === '') { + throw new \RuntimeException('token exchange failed'); + } + + $info = json_decode((string) $http->get($cfg['userinfo_uri'], [ + 'headers' => ['Authorization' => 'Bearer ' . $access], + ])->getBody(), true) ?: []; + } catch (\Throwable $e) { + error_log('[account] google oauth failed: ' . $e->getMessage()); + $this->finishOauth(false); + } + + $sub = (string) ($info['sub'] ?? ''); + $email = strtolower(trim((string) ($info['email'] ?? ''))); + if ($sub === '' || $email === '') { + $this->finishOauth(false); + } + + try { + $row = $this->upsertGoogleUser($sub, $email, $info); + } catch (\Throwable $e) { + error_log('[account] google upsert failed: ' . $e->getMessage()); + $this->finishOauth(false); + } + $this->startSession($row); + $this->finishOauth(true); + } + + // ── internals ───────────────────────────────────────────────────────────── + + /** Read + decrypt the google_oauth row from sp_settings. */ + private function googleConfig(): ?array + { + $meta = json_decode((string) \Db::getValue( + "SELECT metval FROM sp_settings WHERE keyval = 'google_oauth' AND status = 1 LIMIT 1" + ), true); + if (!$meta || empty($meta['client_id'])) { + return null; + } + $meta['client_secret'] = \Functions::Decrypt((string) ($meta['client_secret'] ?? '')); + return $meta; + } + + /** Find-or-create a sp_users row for a Google identity, and link it in sp_oauth_accounts. */ + private function upsertGoogleUser(string $sub, string $email, array $info): array + { + // 1. Already linked → return that user. + $link = \Db::getRow( + "SELECT userid FROM sp_oauth_accounts WHERE provider = 'google' AND provider_uid = ? LIMIT 1", + [$sub] + ); + if ($link) { + return \Db::getRow("SELECT * FROM sp_users WHERE userid = ? LIMIT 1", [$link['userid']]); + } + + // 2. Existing account with this email → link Google to it (and mark verified). + $user = \Db::getRow("SELECT * FROM sp_users WHERE email = ? LIMIT 1", [$email]); + if ($user) { + if ((int) ($user['email_verified'] ?? 0) !== 1) { + \Db::execute("UPDATE sp_users SET email_verified = 1 WHERE userid = ?", [$user['userid']]); + } + } else { + // 3. New user — Google already verified the email, so email_verified = 1. + [$fname, $lname] = $this->splitName((string) ($info['name'] ?? $email)); + $res = json_decode(\Db::insert('sp_users', [ + 'username' => $email, + 'email' => $email, + 'email_verified' => 1, + 'password' => '', + 'fname' => (string) ($info['given_name'] ?? $fname), + 'lname' => (string) ($info['family_name'] ?? $lname), + 'avatar' => (string) ($info['picture'] ?? ''), + 'role_id' => null, // community member — no admin role (FK -> sp_roles allows NULL) + 'active' => 1, + ]), true); + if (($res['Code'] ?? 0) != 1) { + error_log('[account] google user create failed: ' . ($res['Message'] ?? '?')); + $this->finishOauth(false); + } + $user = \Db::getRow("SELECT * FROM sp_users WHERE userid = ? LIMIT 1", [(int) $res['ID']]); + } + + \Db::execute( + "INSERT INTO sp_oauth_accounts (userid, provider, provider_uid, email, created_at) VALUES (?, 'google', ?, ?, NOW())", + [$user['userid'], $sub, $email] + ); + return $user; + } + + /** Send the verification email; falls back to logging the link when email is unconfigured. */ + private function sendVerifyEmail(string $email, string $name, string $token): void + { + $link = URL . '/api/account/verify?token=' . urlencode($token); + + $tpl = dirname(__DIR__, 2) . '/templates/emails/verify.html'; + $html = @file_get_contents($tpl) ?: '

Confirm your email: %VERIFY_URL%

'; + $html = str_replace( + ['%VERIFY_URL%', '%NAME%', '%SITE%'], + [$link, htmlspecialchars($name !== '' ? $name : 'there', ENT_QUOTES), PROJECT_NAME], + $html + ); + + $sent = \Mailer::send($email, $name, 'Confirm your ' . PROJECT_NAME . ' account', $html, "Confirm your email: {$link}"); + if (!$sent) { + // No email provider configured (or send failed) → log the link so signup stays testable. + error_log("[account] verify link for {$email}: {$link}"); + } + } + + /** 302 to a path on the public site, then stop. */ + private function redirect(string $path): void + { + header('Location: ' . URL . $path); + exit; + } + + /** + * Finish the OAuth callback. In popup mode (opened by the AuthModal) we return a tiny + * HTML page that postMessages the result to the opener and closes the popup — so the + * user stays on the page they started from, which just refreshes. Otherwise (fallback + * when the popup was blocked) we 302-redirect to the homepage with a status flag. + */ + private function finishOauth(bool $ok): void + { + $popup = !empty($_SESSION['oauth_popup']); + unset($_SESSION['oauth_popup']); + + if ($popup) { + $origin = json_encode(URL); + $fallback = json_encode(URL . '/?login=' . ($ok ? 'success' : 'error')); + header('Content-Type: text/html; charset=UTF-8'); + echo 'Signing in…' + . '' + . '

You can close this window.

'; + exit; + } + + $this->redirect($ok ? '/?login=success' : '/?login=error'); + } +} diff --git a/api/templates/emails/verify.html b/api/templates/emails/verify.html new file mode 100644 index 0000000..6c6e518 --- /dev/null +++ b/api/templates/emails/verify.html @@ -0,0 +1,34 @@ + + + + + + + +
+ + + + +
+

%SITE%

+

Confirm your email

+

+ Hi %NAME%, welcome to %SITE%. Tap the button below to confirm your email and start joining the conversation. +

+

+ Confirm my email +

+

+ Or paste this link into your browser: +

+

+ %VERIFY_URL% +

+

+ This link expires in 24 hours. If you didn't create an account, you can safely ignore this email. +

+
+
+ +