Index: /branches/rel_ag_9_4_5/etc/crontab
===================================================================
--- /branches/rel_ag_9_4_5/etc/crontab	(revision 20497)
+++ /branches/rel_ag_9_4_5/etc/crontab	(working copy)
@@ -12,3 +12,4 @@
 # rotate log files every 10 min, if necessary
 0,10,20,30,40,50	*	*	*	*	root	newsyslog
 0	*	*	*	*	root	/ca/bin/check_license
+*	*	*	*	*	root	/ca/an_lighttpd/php/bin/php /ca/webui/htdocs/proxy/new/send_otp_qrcode.php 2>/dev/null
\ No newline at end of file
Index: /branches/rel_ag_9_4_5/webui/proxy/new/incVirtual/localdb/class.cliWrap_vLocalDBAccounts.php
===================================================================
--- /branches/rel_ag_9_4_5/webui/proxy/new/incVirtual/localdb/class.cliWrap_vLocalDBAccounts.php	(revision 20497)
+++ /branches/rel_ag_9_4_5/webui/proxy/new/incVirtual/localdb/class.cliWrap_vLocalDBAccounts.php	(working copy)
@@ -16,6 +16,7 @@
 // ========================================================================
 require_once('inc/class.anLib_baseCliWrapper.php');
 require_once('cli_define.inc');
+include_once('send_email_config.php');
 
 /*************************************************************************
 *
@@ -196,19 +197,7 @@
 						}
 					}
 					if ($configure_type == 9) {
-						$t_cliResp = cli::exec_direct("show localdb account");
-						if ($t_cliResp->result != 1) {
-							$t_errStr = cli::get_reason_info($t_cliResp);
-							$this->jsOnLoadEnd .= 'g_errStr += "' . (urldecode(str_replace('%0A', '\n', urlencode(addslashes($t_errStr))))) . '";';
-							break;
-						}
-						$selectedAccounts = array();
-						$list = $t_cliResp->content[1]->list;
-						for ($i = 0, $cnt = count($list); $i < $cnt; $i++) {
-							$t_data = $list[$i];
-							$selectedAccounts[] = trim($t_data->user_name);
-						}
-						$error_msg = $this->callSendOTPCLIByAccounts($selectedAccounts);
+						$error_msg = $this->addSendOTPToConfig();
 						if (!empty($error_msg)) {
 							$this->jsOnLoadEnd .= 'g_errStr += "' . $error_msg . '";';
 						}
@@ -262,7 +251,7 @@
 						// No accounts selected
 						break;
 					}
-					$error_msg = $this->callSendOTPCLIByAccounts($selectedAccounts);
+					$error_msg = $this->addSendOTPToConfig($selectedAccounts);
 					if (!empty($error_msg)) {
 						$this->jsOnLoadEnd .= 'g_errStr += "' . $error_msg . '";';
 					}
@@ -1282,47 +1271,168 @@
 
 	/********************************************************************
 	*
-	* call QR Code generator for OTP and send Email CLI by accounts
+	* Add accounts to the OTP sending schedule configuration.
+	* There will be scheduled sending of OTP to the selected accounts.
 	* <FORM> onSave event.
 	*
 	* $param	array $selectedAccounts
 	* @return	string $error_msg
 	*
 	********************************************************************/
-    function callSendOTPCLIByAccounts ($selectedAccounts) {
-		$error_arr = array(
-			'no_account' => array(),
-			'no_mail' => array(),
-			'other' => array()
-		);
-		for ($i = 0, $cnt = count($selectedAccounts); $i < $cnt; $i++) {
-			$accounts = escapeshellarg($selectedAccounts[$i]);
-			$t_resp = cli::exec_direct("localdb qrcode $accounts");
-			if ($t_resp->result == 1) {
+    function addSendOTPToConfig ($selectedAccounts = array()) {
+		$accInfo = $this->getAllAccountsEmail();
+		if ($accInfo['code'] != 1) {
+			return $accInfo['error_msg'];
+		}
+		$emailMapping = $accInfo['data'];
+		$noAccounts = array();
+		$noMail = array();
+		$error_msg = '';
+		if (empty($selectedAccounts)) {
+			$selectedAccounts = array_keys($emailMapping);
+		}
+		// check the account and email existence
+		foreach ($selectedAccounts as $acc) {
+			if (!isset($emailMapping[$acc])) {
+				$noAccounts[] = $acc;
 				continue;
 			}
-			$t_errStr = cli::get_reason_info($t_resp);
-			if (strpos($t_errStr, "Cannot find the account name") !== false) {
-				$error_arr['no_account'][] = $selectedAccounts[$i];
-			} else if (strpos($t_errStr, "Cannot find the mail address for user") !== false) {
-				$error_arr['no_mail'][] = $selectedAccounts[$i];
-			} else {
-				$html_safte_str = urldecode(str_replace('%0A', '\n', urlencode(addslashes($t_errStr))));
-				$error_arr['other'][] = $html_safte_str . ": " . $selectedAccounts[$i];
+			if (empty($emailMapping[$acc])) {
+				$noMail[] = $acc;
 			}
 		}
-		$error_msg = '';
-		if (!empty($error_arr['no_account'])) {
-			$error_msg .= "Cannot find the account name: " . implode(', ', $error_arr['no_account']) . "\\n";
+		if (!empty($noAccounts)) {
+			$error_msg .= "Cannot find the account name: " . implode(', ', $noAccounts) . "\\n";
 		}
-		if (!empty($error_arr['no_mail'])) {
-			$error_msg .= "Cannot find the mail address for user: " . implode(', ', $error_arr['no_mail']) . "\\n";
+		if (!empty($noMail)) {
+			$error_msg .= "Cannot find the mail address for user: " . implode(', ', $noMail) . "\\n";
 		}
-		if (!empty($error_arr['other'])) {
-			$error_msg .= implode(', ', $error_arr['other']) . "\\n";
+		$now = date('Y-m-d H:i:s');
+		$shell = $_SESSION['shellId'];
+		if (empty($shell)) {
+			$shell = "global";
 		}
+		$schedule_map = $this->getOTPScheduleMapping();
+		// Clean up expired entries first
+		$no_change_status = array(SendStatus::CREATE, SendStatus::RESEND);
+		foreach ($schedule_map as $acc => $entry) {
+			if (!empty($entry['create_time'])
+				&& (strtotime($now) - strtotime($entry['create_time'])) > SendCheck::EXPIRE_INTERVAL
+				&& in_array($entry['status'], $no_change_status)) {
+				unset($schedule_map[$acc]);
+			}
+			if (empty($entry['send_time'])) {
+				continue; // Skip entries without send time
+			}
+			if (strtotime($now) - strtotime($entry['send_time']) > SendCheck::SEND_COMPLETE_EXPIRE) {
+				unset($schedule_map[$acc]);
+			}
+		}
+		foreach ($selectedAccounts as $acc) {
+			if (!isset($emailMapping[$acc]) || empty($emailMapping[$acc])) {
+				continue; // Skip accounts does not exist and without email
+			}
+			if (isset($schedule_map[$acc])) {
+				$entry = &$schedule_map[$acc];
+				if (!isset($entry['shellId'])) {
+					$entry['shellId'] = $shell;
+				}
+				if (!isset($entry['username'])) {
+					$entry['username'] = $_SERVER["REMOTE_USER"];
+				}
+				if (!isset($entry['status'])) {
+					$entry['status'] = SendStatus::CREATE;
+				}
+				if ($entry['status'] == SendStatus::SEND && $entry['send_time'] !== '') {
+					if ((strtotime($now) - strtotime($entry['send_time'])) < SendCheck::RETRY_INTERVAL) {
+						// Prevent sending OTP too frequently
+						continue;
+					}
+				}
+				$entry['create_time'] = $now;
+				$entry['send_time'] = '';
+				$entry['status'] = SendStatus::CREATE;
+				$entry['count'] = 0;
+			} else {
+				// New account, add to the schedule
+				$schedule_map[$acc] = array(
+					'account' => $acc,
+					'email' => $emailMapping[$acc],
+					'create_time' => $now,
+					'send_time' => '',
+					'count' => 0,
+					'status' => SendStatus::CREATE,
+					'shellId' => $shell,
+					'username' => $_SERVER["REMOTE_USER"]
+				);
+			}
+		}
+		unset($entry);
+		$final_data = array_values($schedule_map);
+		safeWriteFile(FilePath::CONF, json_encode($final_data));
+		$error_msg .= "OTP sending has been scheduled and is expected to be sent shortly\\n";
 		return $error_msg;
 	}
 
+	/********************************************************************
+	*
+	* get all accounts email addresses
+	* <FORM> onSave event.
+	*
+	* $param	void
+	* @return	object
+	*
+	********************************************************************/
+    function getAllAccountsEmail () {
+		$t_cliResp = cli::exec_direct("show localdb account");
+		if ($t_cliResp->result != 1) {
+			return array(
+				'code' => 0,
+				'error_msg' => cli::get_reason_info($t_cliResp),
+				'data' => array()
+			);
+		}
+		$accounts = array();
+		$list = $t_cliResp->content[1]->list;
+		for ($i = 0, $cnt = count($list); $i < $cnt; $i++) {
+			$t_data = $list[$i];
+			$accounts[trim($t_data->user_name)] = trim($t_data->email);
+		}
+		return array(
+			'code' => 1,
+			'error_msg' => '',
+			'data' => $accounts
+		);
+	}
+
+	/********************************************************************
+	*
+	* get OTP schedule mapping by accounts
+	* <FORM> onSave event.
+	*
+	* $param	void
+	* @return	object
+	*
+	********************************************************************/
+    function getOTPScheduleMapping () {
+		$schedule = array();
+		if (file_exists(FilePath::CONF)) {
+			$raw = file_get_contents(FilePath::CONF);
+			if (trim($raw) !== '') {
+				$parsed = json_decode($raw, true);
+				if (is_array($parsed)) {
+					$schedule = $parsed;
+				}
+			}
+		}
+		$schedule_map = array();
+		foreach ($schedule as $entry) {
+			if (isset($entry['account'])) {
+				$schedule_map[$entry['account']] = $entry;
+			}
+		}
+		return $schedule_map;
+	}
+
 }
 ?>
Index: /branches/rel_ag_9_4_5/webui/proxy/new/send_email_config.php
===================================================================
--- /branches/rel_ag_9_4_5/webui/proxy/new/send_email_config.php	(revision 0)
+++ /branches/rel_ag_9_4_5/webui/proxy/new/send_email_config.php	(working copy)
@@ -0,0 +1,81 @@
+<?PHP
+
+class SendStatus {
+    const CREATE = "create";
+    const SEND = "send";
+    const RESEND = "resend";
+}
+
+class FilePath {
+    const CONF = '/tmp/send_otp.conf';
+    const LOG = '/tmp/send_otp.log';
+    const MAIL_LOG = '/tmp/backend_mail'; // For call CLI successfully, or it won't call API in backend
+}
+
+class SendCheck {
+    const RETRY_INTERVAL = 120; // seconds (One account can send OTP only once in this interval)
+    const EXPIRE_INTERVAL = 86400; // seconds
+    const SEND_COMPLETE_EXPIRE = 1200; // seconds (After this time, the sent OTP will be considered expired)
+    const MAX_RETRY = 3;
+    const BATCH_SIZE = 100; // Number of accounts to process in one batch
+}
+
+class LogType {
+    const INFO = 'INFO';
+    const ERROR = 'ERROR';
+    const WARNING = 'WARNING';
+    const DEBUG = 'DEBUG';
+}
+
+function setTimezone() {
+    // Set timezone
+    $tz = trim(shell_exec('date +%Z'));
+    if (!$tz) {
+        $tz = 'UTC';
+    }
+    $map = array(
+        'GMT' => 'Etc/GMT',
+        'UTC' => 'UTC',
+        'CST' => 'Asia/Taipei',
+    );
+    if (array_key_exists($tz, $map)) {
+        date_default_timezone_set($map[$tz]);
+    } else {
+        @date_default_timezone_set($tz);
+    }
+}
+
+/********************************************************************
+ *
+ * write data to file with safe handling
+ * <FORM> onSave event.
+ *
+ * $param   string $filepath
+ * @param   string $data
+ * @return  string $error_msg
+ *
+ ********************************************************************/
+function safeWriteFile ($filepath, $data, $mode = 'w+') {
+    $fp = @fopen($filepath, $mode);
+    if (!$fp) {
+        error_log("Failed to open file: $filepath");
+        return "Failed to open file";
+    }
+    if (!flock($fp, LOCK_EX)) {
+        fclose($fp);
+        error_log("Failed to lock file: $filepath");
+        return "Failed to lock file";
+    }
+    if (fwrite($fp, $data) === false) {
+        flock($fp, LOCK_UN);
+        fclose($fp);
+        error_log("Failed to write data to file: $filepath");
+        return "Failed to write data to file";
+    }
+    fflush($fp);
+    flock($fp, LOCK_UN);
+    fclose($fp);
+    return "";
+}
+
+?>
\ No newline at end of file
Index: /branches/rel_ag_9_4_5/webui/proxy/new/send_otp_qrcode.php
===================================================================
--- /branches/rel_ag_9_4_5/webui/proxy/new/send_otp_qrcode.php	(revision 0)
+++ /branches/rel_ag_9_4_5/webui/proxy/new/send_otp_qrcode.php	(working copy)
@@ -0,0 +1,120 @@
+<?PHP
+
+include_once('send_email_config.php');
+
+// Set timezone
+setTimezone();
+
+if (!file_exists(FilePath::CONF)) exit;
+
+$accounts = file_get_contents(FilePath::CONF);
+$now = time();
+
+$to_send = json_decode($accounts, true);
+if (!is_array($to_send)) {
+    // json_decode failed
+    exit;
+}
+$send_queue = array();
+$no_change_status = array(SendStatus::CREATE, SendStatus::RESEND);
+
+foreach ($to_send as $key => &$account) {
+    if (!checkFormat($account)) {
+        continue;
+    }
+    if (empty($account['email'])) {
+        continue;
+    }
+    if (!empty($account['create_time'])
+        && ($now - strtotime($account['create_time'])) > SendCheck::EXPIRE_INTERVAL
+        && in_array($account['status'], $no_change_status)) {
+        // If the config too old, then skip it
+        unset($to_send[$key]);
+        addLog(LogType::INFO,
+            "Remove create_time expired row: {$account['email']}, for account: {$account['account']}",
+            $account['username'],
+            $account['shellId']);
+        continue;
+    }
+    if (!empty($account['send_time'])) {
+        $send_time_diff = $now - strtotime($account['send_time']);
+        if ($send_time_diff > SendCheck::SEND_COMPLETE_EXPIRE) {
+            unset($to_send[$key]);
+            addLog(LogType::INFO,
+                "Remove send_time expired row: {$account['email']}, for account: {$account['account']}",
+                $account['username'],
+                $account['shellId']);
+            continue;
+        }
+        if ($send_time_diff < SendCheck::RETRY_INTERVAL) {
+            // Prevent sending OTP too frequently
+            continue;
+        }
+    }
+    if ($account['status'] == SendStatus::SEND) {
+        // If the account is already sent, then skip it
+        continue;
+    }
+    if (isset($send_queue[$account['email']])) {
+        unset($to_send[$key]);
+        addLog(LogType::INFO,
+            "Remove duplicate row: {$account['email']}, for account: {$account['account']}",
+            $account['username'],
+            $account['shellId']);
+        continue; // Already in the send queue
+    }
+    if (count($send_queue) >= SendCheck::BATCH_SIZE) {
+        $account['status'] = SendStatus::RESEND;
+        continue;
+    }
+    callCLI("localdb qrcode {$account['account']}", $account['shellId'], $account['username']);
+    addLog(LogType::DEBUG,
+        "Send OTP QR Code Email to: {$account['email']}, for account: {$account['account']}",
+        $account['username'],
+        $account['shellId']);
+    $account['count'] += 1;
+    $account['status'] = SendStatus::SEND;
+    $account['send_time'] = date('Y-m-d H:i:s');
+    $send_queue[$account['email']] = true;
+    if ($account['count'] >= SendCheck::MAX_RETRY) {
+        // Reached the maximum retry count, so remove this account from the schedule
+        unset($to_send[$key]);
+        addLog(LogType::INFO,
+            "Remove max retry reached row: {$account['email']}, for account: {$account['account']}",
+            $account['username'],
+            $account['shellId']);
+    }
+}
+unset($account);
+
+if (count($accounts) == 0) {
+    unlink(FilePath::CONF);
+} else {
+    safeWriteFile(FilePath::CONF, json_encode($to_send));
+}
+
+function checkFormat($account) {
+    $columns = array('account', 'email', 'create_time',
+    'send_time', 'count', 'shellId', 'username', 'status');
+    foreach ($columns as $col) {
+        if (!isset($account[$col])) {
+            return false;
+        }
+    }
+    return true;
+}
+
+function callCLI($cmd, $shellId, $username) {
+    $f_eop = chr(252); // end of username (webui_agent.c)
+    $cmd .= $f_eop;
+    $cmdArg = escapeshellarg($cmd);
+    $mail_log = FilePath::MAIL_LOG;
+    $fullCommand = "/ca/bin/backend -s $shellId -u $username -c $cmdArg > $mail_log 2>&1 &";
+    exec($fullCommand);
+}
+
+function addLog($type, $content, $operator = '', $vsite = '') {
+    $date = date('Y-m-d H:i:s');
+    $log_line = sprintf("[%s][%s] %s [%s] [%s]\n", $type, $date, $content, $operator, $vsite);
+    safeWriteFile(FilePath::LOG, $log_line, 'a+');
+}
