Prevents UI re-trigger while email is in flight. Status flow: S (scheduled) → P (processing) → D (delivered) / S (failed, retryable) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
417 lines
13 KiB
PHP
Executable File
417 lines
13 KiB
PHP
Executable File
#!/usr/bin/env php
|
|
<?php
|
|
/**
|
|
* Email queue processor — reads t_send_email (Status=S, IsActive=Y),
|
|
* downloads PDF attachments from T_SendEmailReports URLs,
|
|
* encrypts PDFs with patient DOB password for PATIENT type,
|
|
* sends via SMTP STARTTLS, updates status on completion.
|
|
*
|
|
* Usage:
|
|
* php scripts/send_email.php
|
|
* php scripts/send_email.php --id=17 (process single email)
|
|
* php scripts/send_email.php --dry-run (skip actual send + DB update)
|
|
*/
|
|
|
|
define('DB_HOST', 'localhost');
|
|
define('DB_USER', 'root');
|
|
define('DB_PASS', 'sasone102938');
|
|
define('DB_NAME', 'one_lab');
|
|
define('DB_LOG_NAME', 'one_lab_log');
|
|
define('SMTP_PORT', 587);
|
|
define('EMAIL_SUBJECT', 'Hasil Pemeriksaan Laboratorium');
|
|
|
|
// ─── CLI args ────────────────────────────────────────────────────────────────
|
|
$dry_run = in_array('--dry-run', $argv);
|
|
$only_id = null;
|
|
foreach ($argv as $arg) {
|
|
if (preg_match('/^--id=(\d+)$/', $arg, $m)) {
|
|
$only_id = (int) $m[1];
|
|
}
|
|
}
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
function log_msg(string $msg): void
|
|
{
|
|
echo '[' . date('Y-m-d H:i:s') . '] ' . $msg . PHP_EOL;
|
|
}
|
|
|
|
function download_pdf(string $url)
|
|
{
|
|
$ch = curl_init($url);
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_FOLLOWLOCATION => true,
|
|
CURLOPT_TIMEOUT => 60,
|
|
CURLOPT_SSL_VERIFYPEER => false,
|
|
]);
|
|
$data = curl_exec($ch);
|
|
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
$err = curl_error($ch);
|
|
curl_close($ch);
|
|
|
|
if ($err) {
|
|
log_msg(" curl error: $err");
|
|
return false;
|
|
}
|
|
if ($http_code !== 200 || !$data) {
|
|
log_msg(" HTTP $http_code, empty=" . (empty($data) ? 'yes' : 'no'));
|
|
return false;
|
|
}
|
|
return $data;
|
|
}
|
|
|
|
function encrypt_pdf(string $input_path, string $password)
|
|
{
|
|
$output_path = $input_path . '_enc.pdf';
|
|
$cmd = sprintf(
|
|
'gs -dBATCH -dNOPAUSE -sDEVICE=pdfwrite -dEncryptionR=3 -dKeyLength=128 '
|
|
. '-sOwnerPassword=%s -sUserPassword=%s -sOutputFile=%s %s 2>/dev/null',
|
|
escapeshellarg($password),
|
|
escapeshellarg($password),
|
|
escapeshellarg($output_path),
|
|
escapeshellarg($input_path)
|
|
);
|
|
exec($cmd, $out, $ret);
|
|
if ($ret !== 0 || !file_exists($output_path) || filesize($output_path) === 0) {
|
|
return false;
|
|
}
|
|
return $output_path;
|
|
}
|
|
|
|
// ─── SMTP ─────────────────────────────────────────────────────────────────────
|
|
function smtp_read($socket): string
|
|
{
|
|
$line = '';
|
|
while (!feof($socket)) {
|
|
$ch = fread($socket, 1);
|
|
if ($ch === false) break;
|
|
$line .= $ch;
|
|
if (substr($line, -1) === "\n") break;
|
|
}
|
|
return rtrim($line);
|
|
}
|
|
|
|
function smtp_cmd($socket, string $cmd): string
|
|
{
|
|
fwrite($socket, $cmd . "\r\n");
|
|
return smtp_read($socket);
|
|
}
|
|
|
|
/** Drain multi-line SMTP responses (e.g. 250-xxx ... 250 OK) */
|
|
function smtp_read_all($socket, string $first): string
|
|
{
|
|
$last = $first;
|
|
while (strlen($last) >= 4 && $last[3] === '-') {
|
|
$last = smtp_read($socket);
|
|
}
|
|
return $last;
|
|
}
|
|
|
|
/**
|
|
* @param array $smtp ['server', 'username', 'password']
|
|
* @param string $from_addr
|
|
* @param string $from_name
|
|
* @param string $to
|
|
* @param string $cc empty string = no CC
|
|
* @param string $subject
|
|
* @param string $body_html
|
|
* @param array $attachments [['path'=>..., 'name'=>...], ...]
|
|
* @return string|null null on success, error message on failure
|
|
*/
|
|
function send_email(
|
|
array $smtp,
|
|
string $from_addr,
|
|
string $from_name,
|
|
string $to,
|
|
string $cc,
|
|
string $subject,
|
|
string $body_html,
|
|
array $attachments = []
|
|
) {
|
|
$socket = @fsockopen('tcp://' . $smtp['server'], SMTP_PORT, $errno, $errstr, 30);
|
|
if (!$socket) {
|
|
return "Connection to {$smtp['server']}:" . SMTP_PORT . " failed: $errstr ($errno)";
|
|
}
|
|
stream_set_timeout($socket, 30);
|
|
|
|
smtp_read($socket); // greeting
|
|
|
|
$resp = smtp_cmd($socket, 'EHLO localhost');
|
|
smtp_read_all($socket, $resp);
|
|
|
|
$resp = smtp_cmd($socket, 'STARTTLS');
|
|
if (strpos($resp, '220') === false) {
|
|
fclose($socket);
|
|
return "STARTTLS rejected: $resp";
|
|
}
|
|
|
|
if (!stream_socket_enable_crypto($socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
|
|
fclose($socket);
|
|
return "TLS upgrade failed";
|
|
}
|
|
|
|
$resp = smtp_cmd($socket, 'EHLO localhost');
|
|
smtp_read_all($socket, $resp);
|
|
|
|
smtp_cmd($socket, 'AUTH LOGIN');
|
|
smtp_cmd($socket, base64_encode($smtp['username']));
|
|
$resp = smtp_cmd($socket, base64_encode($smtp['password']));
|
|
if (strpos($resp, '235') === false) {
|
|
fclose($socket);
|
|
return "AUTH failed: $resp";
|
|
}
|
|
|
|
smtp_cmd($socket, "MAIL FROM:<{$from_addr}>");
|
|
smtp_cmd($socket, "RCPT TO:<{$to}>");
|
|
if ($cc !== '') {
|
|
smtp_cmd($socket, "RCPT TO:<{$cc}>");
|
|
}
|
|
smtp_cmd($socket, 'DATA');
|
|
|
|
$boundary = md5(uniqid((string) rand(), true));
|
|
|
|
$msg = "From: {$from_name} <{$from_addr}>\r\n";
|
|
$msg .= "To: {$to}\r\n";
|
|
if ($cc !== '') {
|
|
$msg .= "Cc: {$cc}\r\n";
|
|
}
|
|
$msg .= 'Subject: =?UTF-8?B?' . base64_encode($subject) . "?=\r\n";
|
|
$msg .= "MIME-Version: 1.0\r\n";
|
|
$msg .= "Content-Type: multipart/mixed; boundary=\"{$boundary}\"\r\n";
|
|
$msg .= "\r\n";
|
|
|
|
// HTML body part
|
|
$msg .= "--{$boundary}\r\n";
|
|
$msg .= "Content-Type: text/html; charset=UTF-8\r\n";
|
|
$msg .= "Content-Transfer-Encoding: base64\r\n\r\n";
|
|
$msg .= chunk_split(base64_encode($body_html)) . "\r\n";
|
|
|
|
// PDF attachments
|
|
foreach ($attachments as $att) {
|
|
$raw = file_get_contents($att['path']);
|
|
if ($raw === false) continue;
|
|
$msg .= "--{$boundary}\r\n";
|
|
$msg .= "Content-Type: application/pdf\r\n";
|
|
$msg .= "Content-Transfer-Encoding: base64\r\n";
|
|
$msg .= "Content-Disposition: attachment; filename=\"{$att['name']}\"\r\n\r\n";
|
|
$msg .= chunk_split(base64_encode($raw)) . "\r\n";
|
|
}
|
|
|
|
$msg .= "--{$boundary}--\r\n";
|
|
|
|
// RFC 2821: lines beginning with "." must be dot-stuffed
|
|
$msg = preg_replace('/^\.$/m', '..', $msg);
|
|
|
|
fwrite($socket, $msg . "\r\n.\r\n");
|
|
$resp = smtp_read($socket);
|
|
smtp_cmd($socket, 'QUIT');
|
|
fclose($socket);
|
|
|
|
return (strpos($resp, '250') !== false) ? null : "DATA rejected: $resp";
|
|
}
|
|
|
|
// ─── Main ─────────────────────────────────────────────────────────────────────
|
|
try {
|
|
$pdo = new PDO(
|
|
'mysql:host=' . DB_HOST . ';dbname=' . DB_NAME . ';charset=utf8',
|
|
DB_USER,
|
|
DB_PASS,
|
|
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
|
|
);
|
|
$pdo_log = new PDO(
|
|
'mysql:host=' . DB_HOST . ';dbname=' . DB_LOG_NAME . ';charset=utf8',
|
|
DB_USER,
|
|
DB_PASS,
|
|
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
|
|
);
|
|
} catch (PDOException $e) {
|
|
log_msg('DB connect failed: ' . $e->getMessage());
|
|
exit(1);
|
|
}
|
|
|
|
// Email config
|
|
$cfg = $pdo->query(
|
|
"SELECT * FROM m_emailconfig WHERE M_EmailConfigIsActive = 'Y' LIMIT 1"
|
|
)->fetch(PDO::FETCH_ASSOC);
|
|
|
|
if (!$cfg) {
|
|
log_msg('No active email config found');
|
|
exit(1);
|
|
}
|
|
|
|
$smtp = [
|
|
'server' => $cfg['M_EmailConfigServer'],
|
|
'username' => $cfg['M_EmailConfigUsername'],
|
|
'password' => $cfg['M_EmailConfigPassword'],
|
|
];
|
|
$from_addr = $cfg['M_EmailConfigUsername'];
|
|
$from_name = $cfg['M_EmailConfigSender'];
|
|
$cc = (string) ($cfg['M_EmailConfigCc'] ?? '');
|
|
$max_retry = (int) $cfg['M_EmailConfigMaxRetry'];
|
|
|
|
// Pending queue
|
|
$where_id = $only_id ? 'AND e.T_SendEmailID = ' . $only_id : '';
|
|
$sql = "
|
|
SELECT
|
|
e.T_SendEmailID,
|
|
e.T_SendEmailT_OrderHeaderID,
|
|
e.T_SendEmailRecepient,
|
|
e.T_SendEmailRecepientType,
|
|
e.T_SendEmailPatientName,
|
|
e.T_SendEmailNarratives,
|
|
e.T_SendEmailReports,
|
|
e.T_SendEmailCount,
|
|
p.M_PatientDOB
|
|
FROM t_send_email e
|
|
LEFT JOIN t_orderheader oh
|
|
ON oh.T_OrderHeaderID = e.T_SendEmailT_OrderHeaderID
|
|
LEFT JOIN m_patient p
|
|
ON p.M_PatientID = oh.T_OrderHeaderM_PatientID
|
|
WHERE e.T_SendEmailIsActive = 'Y'
|
|
AND e.T_SendEmailStatus IN ('S')
|
|
AND e.T_SendEmailCount < {$max_retry}
|
|
{$where_id}
|
|
ORDER BY e.T_SendEmailID ASC
|
|
";
|
|
$rows = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
|
|
|
|
log_msg('Found ' . count($rows) . ' pending email(s)' . ($dry_run ? ' [DRY RUN]' : ''));
|
|
|
|
foreach ($rows as $row) {
|
|
$id = (int) $row['T_SendEmailID'];
|
|
$order_id = (int) $row['T_SendEmailT_OrderHeaderID'];
|
|
$recipient = $row['T_SendEmailRecepient'];
|
|
$rec_type = $row['T_SendEmailRecepientType'];
|
|
$body_html = $row['T_SendEmailNarratives'] ?? '';
|
|
$dob = $row['M_PatientDOB'] ?? '';
|
|
$password = str_replace('-', '', $dob);
|
|
$reports = json_decode($row['T_SendEmailReports'] ?? '[]', true) ?: [];
|
|
|
|
log_msg("Processing ID {$id} → {$recipient} ({$rec_type})");
|
|
|
|
$attachments = [];
|
|
$tmp_files = [];
|
|
|
|
foreach ($reports as $idx => $entry) {
|
|
// Support both formats:
|
|
// old: ["http://..."]
|
|
// new: [{"id":"1","url":"http://...","result":"LAB"}]
|
|
if (is_array($entry)) {
|
|
$url = $entry['url'] ?? '';
|
|
$result = $entry['result'] ?? ('hasil_lab_' . ($idx + 1));
|
|
} else {
|
|
$url = (string) $entry;
|
|
$result = 'hasil_lab_' . ($idx + 1);
|
|
}
|
|
|
|
if (empty($url)) continue;
|
|
|
|
log_msg(" Downloading attachment " . ($idx + 1) . " [{$result}]: {$url}");
|
|
|
|
$pdf = download_pdf($url);
|
|
if ($pdf === false) {
|
|
log_msg(" Download failed, skipping");
|
|
continue;
|
|
}
|
|
|
|
if (substr($pdf, 0, 4) !== '%PDF') {
|
|
log_msg(" Not a PDF (got HTML/error response), skipping");
|
|
continue;
|
|
}
|
|
|
|
$tmp = tempnam(sys_get_temp_dir(), 'ibl_email_') . '.pdf';
|
|
file_put_contents($tmp, $pdf);
|
|
$tmp_files[] = $tmp;
|
|
$final = $tmp;
|
|
|
|
if ($rec_type === 'PATIENT' && $password !== '') {
|
|
log_msg(" Encrypting PDF (password: {$password})");
|
|
$enc = encrypt_pdf($tmp, $password);
|
|
if ($enc) {
|
|
$tmp_files[] = $enc;
|
|
$final = $enc;
|
|
} else {
|
|
log_msg(" Encryption failed — attaching unencrypted");
|
|
}
|
|
}
|
|
|
|
$safe_result = preg_replace('/[^a-zA-Z0-9_\-]/', '_', $result);
|
|
$attachments[] = [
|
|
'path' => $final,
|
|
'name' => $safe_result . '.pdf',
|
|
];
|
|
}
|
|
|
|
if ($dry_run) {
|
|
log_msg(" [DRY RUN] would send to {$recipient} with " . count($attachments) . " attachment(s)");
|
|
foreach ($tmp_files as $f) {
|
|
if (file_exists($f)) unlink($f);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Lock: tandai sedang dikirim agar tidak bisa di-trigger ulang dari UI
|
|
$pdo->prepare("
|
|
UPDATE t_send_email
|
|
SET T_SendEmailStatus = 'P',
|
|
T_SendEmailLastUpdated = NOW()
|
|
WHERE T_SendEmailID = ?
|
|
")->execute([$id]);
|
|
|
|
$err = send_email(
|
|
$smtp,
|
|
$from_addr,
|
|
$from_name,
|
|
$recipient,
|
|
$cc,
|
|
EMAIL_SUBJECT,
|
|
$body_html,
|
|
$attachments
|
|
);
|
|
|
|
foreach ($tmp_files as $f) {
|
|
if (file_exists($f)) unlink($f);
|
|
}
|
|
|
|
if ($err === null) {
|
|
log_msg(" Sent OK");
|
|
$pdo->prepare("
|
|
UPDATE t_send_email
|
|
SET T_SendEmailStatus = 'D',
|
|
T_SendEmailCount = T_SendEmailCount + 1,
|
|
T_SendEmailReceived = NOW(),
|
|
T_SendEmailLastUpdated = NOW()
|
|
WHERE T_SendEmailID = ?
|
|
")->execute([$id]);
|
|
$group_result_names = array_filter(array_column(
|
|
array_filter($reports, 'is_array'),
|
|
'result'
|
|
));
|
|
$pdo_log->prepare("
|
|
INSERT INTO t_send_email_log
|
|
(T_SendEmailLogT_OrderHeaderID, T_SendEmailLogRecepient,
|
|
T_SendEmailLogStatus, T_SendEmailLogResponse,
|
|
T_SendEmailLogGroup_ResultName, T_SendEmailLogJson,
|
|
T_SendEmailLogCreated, T_SendEmailLogCreatedUserID)
|
|
VALUES (?, ?, 'D', NULL, ?, ?, NOW(), 0)
|
|
")->execute([
|
|
$order_id,
|
|
$recipient,
|
|
implode(', ', $group_result_names),
|
|
json_encode($row),
|
|
]);
|
|
} else {
|
|
log_msg(" Error: {$err}");
|
|
$pdo->prepare("
|
|
UPDATE t_send_email
|
|
SET T_SendEmailStatus = 'S',
|
|
T_SendEmailCount = T_SendEmailCount + 1,
|
|
T_SendEmailResponse = ?,
|
|
T_SendEmailLastUpdated = NOW()
|
|
WHERE T_SendEmailID = ?
|
|
")->execute([$err, $id]);
|
|
}
|
|
}
|
|
|
|
log_msg('Done');
|