Initial commit — Rosary Presenter App
Full source for loveandrosary.com: slide-based Rosary/novena/Divine Mercy Chaplet presentation tool with multi-user roles, SVG bead ring, audio uploads, donate strip, and public session profiles. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
/**
|
||||
* includes/auth.php — multi-user role-based authentication.
|
||||
*/
|
||||
|
||||
function _auth_start(): void {
|
||||
if (session_status() === PHP_SESSION_NONE) session_start();
|
||||
}
|
||||
|
||||
/** Redirect to login if not authenticated. */
|
||||
function require_auth(): void {
|
||||
_auth_start();
|
||||
if (empty($_SESSION['user_id'])) {
|
||||
header('Location: ' . BASE_URL . '/login');
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
/** Redirect/abort if user doesn't have the minimum role. */
|
||||
function require_role(string $min_role): void {
|
||||
require_auth();
|
||||
if (!has_role($min_role)) {
|
||||
http_response_code(403);
|
||||
echo '<!DOCTYPE html><html><body style="font-family:system-ui;max-width:500px;margin:60px auto;text-align:center">'
|
||||
. '<h1 style="color:#dc2626">Access Denied</h1>'
|
||||
. '<p>You do not have permission to view this page.</p>'
|
||||
. '<a href="' . BASE_URL . '/admin/">← Dashboard</a></body></html>';
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
/** True if current session user has at least $min_role. */
|
||||
function has_role(string $min): bool {
|
||||
_auth_start();
|
||||
$levels = ['user' => 1, 'superuser' => 2, 'admin' => 3, 'superadmin' => 4];
|
||||
return ($levels[$_SESSION['role'] ?? ''] ?? 0) >= ($levels[$min] ?? 999);
|
||||
}
|
||||
|
||||
/** Return current user data from session (or empty defaults). */
|
||||
function current_user(): array {
|
||||
_auth_start();
|
||||
return [
|
||||
'id' => $_SESSION['user_id'] ?? null,
|
||||
'username' => $_SESSION['username'] ?? '',
|
||||
'email' => $_SESSION['email'] ?? '',
|
||||
'role' => $_SESSION['role'] ?? '',
|
||||
'display_name' => $_SESSION['display_name'] ?? '',
|
||||
'rosary_limit' => $_SESSION['rosary_limit'] ?? 1,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can create another rosary.
|
||||
* Novenas count as 1 regardless of number of days.
|
||||
* Returns true if under limit (or limit is -1 = unlimited).
|
||||
*/
|
||||
function can_create_rosary(int $user_id, int $limit): bool {
|
||||
if ($limit < 0) return true; // unlimited
|
||||
$pdo = get_pdo();
|
||||
$st = $pdo->prepare("
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM sessions WHERE user_id = ? AND occasion != 'novena_deceased') +
|
||||
(SELECT COUNT(*) FROM novena_groups WHERE user_id = ?)
|
||||
AS total
|
||||
");
|
||||
$st->execute([$user_id, $user_id]);
|
||||
return (int)$st->fetchColumn() < $limit;
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
<?php
|
||||
/**
|
||||
* includes/build_slides.php
|
||||
*
|
||||
* build_slides(array $session): array
|
||||
*
|
||||
* Assembles the full slide sequence for a rosary session.
|
||||
* Applies variable substitution for {name}, {pronoun}, {pronoun_obj}, {pronoun_poss}.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../data/prayers.php';
|
||||
|
||||
/**
|
||||
* Returns the slide array for one decade of the Chaplet of Divine Mercy.
|
||||
*
|
||||
* Large bead : "Eternal Father…"
|
||||
* Small beads: "For the sake of His sorrowful Passion…" × 10
|
||||
* No mystery announcement; no Glory Be between decades.
|
||||
*
|
||||
* @param int $decade_num 1–5
|
||||
* @param int $of_bead_index bead_index for the large (Our Father) bead
|
||||
* @param int $hm_bead_start bead_index for the first small bead of this decade
|
||||
*/
|
||||
function build_chaplet_decade_slides(int $decade_num, int $of_bead_index, int $hm_bead_start): array {
|
||||
$slides = [];
|
||||
|
||||
$slides[] = [
|
||||
'id' => 'dm_eternal_father_d' . $decade_num,
|
||||
'type' => 'prayer',
|
||||
'section' => 'dm_chaplet_decade_' . $decade_num,
|
||||
'title' => 'Eternal Father',
|
||||
'leader' => "Eternal Father, I offer You the Body and Blood,\nSoul and Divinity of Your dearly beloved Son,\nOur Lord Jesus Christ,",
|
||||
'all' => "in atonement for our sins and those of the whole world.",
|
||||
'bead' => 'large',
|
||||
'bead_index' => $of_bead_index,
|
||||
];
|
||||
|
||||
for ($i = 0; $i < 10; $i++) {
|
||||
$slides[] = [
|
||||
'id' => 'dm_for_sake_d' . $decade_num . '_' . ($i + 1),
|
||||
'type' => 'prayer',
|
||||
'section' => 'dm_chaplet_decade_' . $decade_num,
|
||||
'title' => 'For the Sake of His Sorrowful Passion',
|
||||
'leader' => 'For the sake of His sorrowful Passion,',
|
||||
'all' => 'have mercy on us and on the whole world.',
|
||||
'bead' => 'small',
|
||||
'bead_index' => $hm_bead_start + $i,
|
||||
];
|
||||
}
|
||||
|
||||
return $slides;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the complete slide array for a session.
|
||||
*
|
||||
* @param array $session Row from the sessions table (keys match column names)
|
||||
* @return array Flat array of slide arrays
|
||||
*/
|
||||
function build_slides(array $session): array {
|
||||
global $opening, $mysteries, $hail_holy_queen, $rosary_closing_prayer,
|
||||
$litany_passion, $novena_prayers, $litany_departed, $closing,
|
||||
$divine_mercy_opening, $divine_mercy_novena_prayers,
|
||||
$divine_mercy_chaplet_opening, $divine_mercy_chaplet_close;
|
||||
|
||||
$slides = [];
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 1. Cover slide
|
||||
// -----------------------------------------------------------------------
|
||||
$cover_title = '';
|
||||
$cover_all = '';
|
||||
|
||||
switch ($session['occasion']) {
|
||||
case 'novena_deceased':
|
||||
$day = (int)($session['novena_day'] ?? 1);
|
||||
$cover_title = 'Nine-Day Novena Rosary';
|
||||
$cover_all = "In Loving Memory of\n{name}\n{subject_dates}\n\nDay {$day} of 9";
|
||||
break;
|
||||
case 'memorial':
|
||||
$cover_title = 'Memorial Rosary';
|
||||
$cover_all = "In Loving Memory of\n{name}\n{subject_dates}";
|
||||
break;
|
||||
case 'divine_mercy_novena':
|
||||
$day = (int)($session['novena_day'] ?? 1);
|
||||
$cover_title = 'Divine Mercy Novena';
|
||||
$cover_all = "Chaplet of Divine Mercy\n\nDay {$day} of 9";
|
||||
break;
|
||||
case 'general_rosary':
|
||||
default:
|
||||
$cover_title = 'The Holy Rosary';
|
||||
$cover_all = ucfirst($session['mystery_set']) . ' Mysteries';
|
||||
break;
|
||||
}
|
||||
|
||||
$slides[] = [
|
||||
'id' => 'cover',
|
||||
'type' => 'cover',
|
||||
'section' => 'cover',
|
||||
'title' => $cover_title,
|
||||
'leader' => '',
|
||||
'all' => $cover_all,
|
||||
'bead' => null,
|
||||
'bead_index' => null,
|
||||
'photo_path' => $session['photo_path'] ?? null,
|
||||
];
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 2. Opening prayers
|
||||
// -----------------------------------------------------------------------
|
||||
if ($session['occasion'] === 'divine_mercy_novena') {
|
||||
// Pre-chaplet devotion: opening prayer + O Blood and Water × 3
|
||||
foreach ($divine_mercy_opening as $slide) {
|
||||
$slides[] = $slide;
|
||||
}
|
||||
// Day-specific intention and prayer
|
||||
$day = (int)($session['novena_day'] ?? 1);
|
||||
if (isset($divine_mercy_novena_prayers[$day])) {
|
||||
foreach ($divine_mercy_novena_prayers[$day] as $slide) {
|
||||
$slides[] = $slide;
|
||||
}
|
||||
}
|
||||
// Chaplet opening on stem beads (Sign of Cross, Our Father, Hail Mary, Creed)
|
||||
foreach ($divine_mercy_chaplet_opening as $slide) {
|
||||
$slides[] = $slide;
|
||||
}
|
||||
} else {
|
||||
foreach ($opening as $slide) {
|
||||
$slides[] = $slide;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 3. Five decades
|
||||
//
|
||||
// Bead layout:
|
||||
// Bead 0: Crucifix (Sign of Cross / Creed)
|
||||
// Bead 1: large (Our Father, stem)
|
||||
// Beads 2–4: small (3 Hail Marys / Creed for chaplet, stem)
|
||||
// Bead 5: large (Our Father, Decade 1)
|
||||
// Beads 6–15: small (Decade 1, 10 HMs / For the sake…)
|
||||
// Bead 16: large (Our Father, Decade 2)
|
||||
// Beads 17–26: small (Decade 2)
|
||||
// Bead 27: large (Our Father, Decade 3)
|
||||
// Beads 28–37: small (Decade 3)
|
||||
// Bead 38: large (Our Father, Decade 4)
|
||||
// Beads 39–48: small (Decade 4)
|
||||
// Bead 49: large (Our Father, Decade 5)
|
||||
// Beads 50–59: small (Decade 5)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
$decade_bead_map = [
|
||||
// decade => [our_father_index, first_hm_index]
|
||||
1 => [5, 6],
|
||||
2 => [16, 17],
|
||||
3 => [27, 28],
|
||||
4 => [38, 39],
|
||||
5 => [49, 50],
|
||||
];
|
||||
|
||||
if ($session['occasion'] === 'divine_mercy_novena') {
|
||||
for ($d = 1; $d <= 5; $d++) {
|
||||
[$of_idx, $hm_idx] = $decade_bead_map[$d];
|
||||
foreach (build_chaplet_decade_slides($d, $of_idx, $hm_idx) as $slide) {
|
||||
$slides[] = $slide;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$mystery_set = $mysteries[$session['mystery_set']] ?? $mysteries['sorrowful'];
|
||||
|
||||
for ($d = 1; $d <= 5; $d++) {
|
||||
[$of_idx, $hm_idx] = $decade_bead_map[$d];
|
||||
$mystery_slide = $mystery_set[$d - 1];
|
||||
|
||||
$decade_slides = build_decade_slides($d, $mystery_slide, $of_idx, $hm_idx);
|
||||
foreach ($decade_slides as $slide) {
|
||||
$slides[] = $slide;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 4. Post-decade prayers by occasion
|
||||
// -----------------------------------------------------------------------
|
||||
switch ($session['occasion']) {
|
||||
case 'novena_deceased':
|
||||
// Hail Holy Queen
|
||||
foreach ($hail_holy_queen as $slide) {
|
||||
$slides[] = $slide;
|
||||
}
|
||||
// Day-specific novena prayer
|
||||
$day = (int)($session['novena_day'] ?? 1);
|
||||
if (isset($novena_prayers[$day])) {
|
||||
$slides[] = $novena_prayers[$day];
|
||||
}
|
||||
// Litany of the Passion
|
||||
foreach ($litany_passion as $slide) {
|
||||
$slides[] = $slide;
|
||||
}
|
||||
// Litany for the Departed
|
||||
foreach ($litany_departed as $slide) {
|
||||
$slides[] = $slide;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'divine_mercy_novena':
|
||||
// Holy God, Holy Mighty One, Holy Immortal One × 3
|
||||
foreach ($divine_mercy_chaplet_close as $slide) {
|
||||
$slides[] = $slide;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'memorial':
|
||||
case 'general_rosary':
|
||||
default:
|
||||
foreach ($hail_holy_queen as $slide) {
|
||||
$slides[] = $slide;
|
||||
}
|
||||
$slides[] = $rosary_closing_prayer;
|
||||
break;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 5. Closing slide — inject photo for personal occasions
|
||||
// -----------------------------------------------------------------------
|
||||
$occasion_key = $session['occasion'];
|
||||
if (!isset($closing[$occasion_key])) {
|
||||
$occasion_key = 'general_rosary';
|
||||
}
|
||||
$closing_slide = $closing[$occasion_key];
|
||||
if (in_array($occasion_key, ['novena_deceased', 'memorial'])) {
|
||||
$closing_slide['photo_path'] = $session['photo_path'] ?? null;
|
||||
}
|
||||
$slides[] = $closing_slide;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 6. Variable substitution
|
||||
// -----------------------------------------------------------------------
|
||||
$name = $session['subject_name'] ?? '';
|
||||
$pronoun = $session['subject_pronoun'] ?? 'he'; // 'he' or 'she'
|
||||
$dates = $session['subject_dates'] ?? '';
|
||||
|
||||
$pronoun_obj = ($pronoun === 'she') ? 'her' : 'him';
|
||||
$pronoun_poss = ($pronoun === 'she') ? 'her' : 'his';
|
||||
$pronoun_cap = ucfirst($pronoun);
|
||||
|
||||
$find = ['{name}', '{pronoun}', '{pronoun_obj}', '{pronoun_poss}', '{subject_dates}'];
|
||||
$replace = [$name, $pronoun, $pronoun_obj, $pronoun_poss, $dates];
|
||||
|
||||
foreach ($slides as &$slide) {
|
||||
$slide['title'] = str_replace($find, $replace, $slide['title'] ?? '');
|
||||
$slide['leader'] = str_replace($find, $replace, $slide['leader'] ?? '');
|
||||
$slide['all'] = str_replace($find, $replace, $slide['all'] ?? '');
|
||||
if (isset($slide['subtitle'])) {
|
||||
$slide['subtitle'] = str_replace($find, $replace, $slide['subtitle']);
|
||||
}
|
||||
}
|
||||
unset($slide);
|
||||
|
||||
return $slides;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
/**
|
||||
* includes/donate.php — Renders the public donate strip if enabled.
|
||||
* Include on any public page: require_once __DIR__ . '/includes/donate.php';
|
||||
* Then call: render_donate_strip();
|
||||
*/
|
||||
|
||||
function render_donate_strip(): void {
|
||||
if (!get_setting('donate_enabled', '0')) return;
|
||||
|
||||
$type = get_setting('donate_type', 'custom');
|
||||
$handle = trim(get_setting('donate_handle', ''));
|
||||
$label = trim(get_setting('donate_label', ''));
|
||||
|
||||
if ($handle === '') return;
|
||||
|
||||
// Build URL and default label by type
|
||||
switch ($type) {
|
||||
case 'paypal':
|
||||
$url = 'https://paypal.me/' . rawurlencode(ltrim($handle, '@'));
|
||||
$icon = '💛'; // 💛
|
||||
$label = $label ?: 'Support via PayPal';
|
||||
break;
|
||||
case 'venmo':
|
||||
$url = 'https://venmo.com/u/' . rawurlencode(ltrim($handle, '@'));
|
||||
$icon = '💸'; // 💸
|
||||
$label = $label ?: '@' . ltrim($handle, '@') . ' on Venmo';
|
||||
break;
|
||||
case 'buymeacoffee':
|
||||
$url = 'https://buymeacoffee.com/' . rawurlencode(ltrim($handle, '@'));
|
||||
$icon = '☕'; // ☕
|
||||
$label = $label ?: 'Buy Me a Coffee';
|
||||
break;
|
||||
default: // custom
|
||||
$url = $handle; // full URL stored in handle field for custom
|
||||
$icon = '❤'; // ❤
|
||||
$label = $label ?: 'Support This Ministry';
|
||||
break;
|
||||
}
|
||||
?>
|
||||
<div class="donate-strip">
|
||||
<span class="donate-strip-text">Help keep this site running</span>
|
||||
<a href="<?= htmlspecialchars($url) ?>" target="_blank" rel="noopener" class="donate-strip-link">
|
||||
<?= $icon ?> <?= htmlspecialchars($label) ?>
|
||||
</a>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
/**
|
||||
* includes/mailer.php — lightweight SMTP mailer using stream_socket_client().
|
||||
* Falls back to PHP mail() when smtp_host is not configured.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Send an email.
|
||||
*
|
||||
* @param string $to_email Recipient email address
|
||||
* @param string $to_name Recipient display name
|
||||
* @param string $subject Email subject
|
||||
* @param string $html HTML body
|
||||
* @param string $text Plain-text body (auto-generated from HTML if empty)
|
||||
* @return bool True on success, false on failure
|
||||
*/
|
||||
function send_email(string $to_email, string $to_name, string $subject, string $html, string $text = ''): bool {
|
||||
$smtp_host = get_setting('smtp_host');
|
||||
$smtp_port = (int) get_setting('smtp_port', '587');
|
||||
$smtp_user = get_setting('smtp_user');
|
||||
$smtp_pass = get_setting('smtp_pass');
|
||||
$smtp_from = get_setting('smtp_from');
|
||||
$smtp_from_name = get_setting('smtp_from_name', 'Rosary Presenter');
|
||||
|
||||
if ($text === '') {
|
||||
$text = strip_tags(preg_replace('/<br\s*\/?>/i', "\n", $html));
|
||||
}
|
||||
|
||||
// Fall back to PHP mail() if SMTP is not configured
|
||||
if ($smtp_host === '') {
|
||||
$headers = "MIME-Version: 1.0\r\n";
|
||||
$headers .= "Content-Type: text/html; charset=UTF-8\r\n";
|
||||
if ($smtp_from !== '') {
|
||||
$headers .= 'From: ' . _mail_encode_name($smtp_from_name) . ' <' . $smtp_from . ">\r\n";
|
||||
}
|
||||
return @mail($to_email, $subject, $html, $headers);
|
||||
}
|
||||
|
||||
try {
|
||||
return _smtp_send($smtp_host, $smtp_port, $smtp_user, $smtp_pass, $smtp_from, $smtp_from_name, $to_email, $to_name, $subject, $html, $text);
|
||||
} catch (Throwable $e) {
|
||||
error_log('Mailer error: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a simple styled HTML email wrapper.
|
||||
*/
|
||||
function email_template(string $title, string $body_html): string {
|
||||
$site_name = get_setting('site_name', 'Rosary Presenter');
|
||||
return <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{$title}</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:#f4f4f5;font-family:system-ui,-apple-system,sans-serif">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f5;padding:40px 20px">
|
||||
<tr><td align="center">
|
||||
<table width="600" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:8px;overflow:hidden;max-width:600px;width:100%">
|
||||
<tr>
|
||||
<td style="background:#1e3a5f;padding:24px 32px;text-align:center">
|
||||
<h1 style="margin:0;color:#ffffff;font-size:22px;font-weight:600">✝ {$site_name}</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:32px">
|
||||
{$body_html}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="background:#f4f4f5;padding:20px 32px;text-align:center;color:#6b7280;font-size:13px">
|
||||
© <?= date('Y') ?> {$site_name}. This email was sent automatically.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
HTML;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal SMTP helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function _smtp_send(
|
||||
string $host, int $port,
|
||||
string $user, string $pass,
|
||||
string $from_addr, string $from_name,
|
||||
string $to_addr, string $to_name,
|
||||
string $subject, string $html, string $text
|
||||
): bool {
|
||||
$use_ssl = ($port === 465);
|
||||
$socket_addr = ($use_ssl ? 'ssl://' : 'tcp://') . $host . ':' . $port;
|
||||
|
||||
$errno = 0;
|
||||
$errstr = '';
|
||||
$sock = stream_socket_client($socket_addr, $errno, $errstr, 15);
|
||||
if (!$sock) {
|
||||
throw new RuntimeException("SMTP connect failed ({$errno}): {$errstr}");
|
||||
}
|
||||
stream_set_timeout($sock, 15);
|
||||
|
||||
_smtp_expect($sock, 220);
|
||||
|
||||
_smtp_cmd($sock, 'EHLO ' . gethostname());
|
||||
$ehlo = _smtp_read_all($sock);
|
||||
|
||||
// STARTTLS for port 587
|
||||
if (!$use_ssl && strpos($ehlo, 'STARTTLS') !== false) {
|
||||
_smtp_cmd($sock, 'STARTTLS');
|
||||
_smtp_expect($sock, 220);
|
||||
if (!stream_socket_enable_crypto($sock, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
|
||||
throw new RuntimeException('STARTTLS negotiation failed');
|
||||
}
|
||||
_smtp_cmd($sock, 'EHLO ' . gethostname());
|
||||
_smtp_read_all($sock);
|
||||
}
|
||||
|
||||
// AUTH LOGIN
|
||||
if ($user !== '') {
|
||||
_smtp_cmd($sock, 'AUTH LOGIN');
|
||||
_smtp_expect($sock, 334);
|
||||
_smtp_cmd($sock, base64_encode($user));
|
||||
_smtp_expect($sock, 334);
|
||||
_smtp_cmd($sock, base64_encode($pass));
|
||||
_smtp_expect($sock, 235);
|
||||
}
|
||||
|
||||
_smtp_cmd($sock, 'MAIL FROM:<' . $from_addr . '>');
|
||||
_smtp_expect($sock, 250);
|
||||
_smtp_cmd($sock, 'RCPT TO:<' . $to_addr . '>');
|
||||
_smtp_expect($sock, [250, 251]);
|
||||
_smtp_cmd($sock, 'DATA');
|
||||
_smtp_expect($sock, 354);
|
||||
|
||||
$boundary = 'b_' . bin2hex(random_bytes(8));
|
||||
$date = date('r');
|
||||
$msg_id = bin2hex(random_bytes(12)) . '@' . gethostname();
|
||||
|
||||
$headers = "Date: {$date}\r\n";
|
||||
$headers .= 'From: ' . _mail_encode_name($from_name) . ' <' . $from_addr . ">\r\n";
|
||||
$headers .= 'To: ' . _mail_encode_name($to_name) . ' <' . $to_addr . ">\r\n";
|
||||
$headers .= 'Subject: ' . _mail_encode_name($subject) . "\r\n";
|
||||
$headers .= "Message-ID: <{$msg_id}>\r\n";
|
||||
$headers .= "MIME-Version: 1.0\r\n";
|
||||
$headers .= "Content-Type: multipart/alternative; boundary=\"{$boundary}\"\r\n";
|
||||
|
||||
$body = "--{$boundary}\r\n";
|
||||
$body .= "Content-Type: text/plain; charset=UTF-8\r\n";
|
||||
$body .= "Content-Transfer-Encoding: quoted-printable\r\n\r\n";
|
||||
$body .= quoted_printable_encode($text) . "\r\n";
|
||||
$body .= "--{$boundary}\r\n";
|
||||
$body .= "Content-Type: text/html; charset=UTF-8\r\n";
|
||||
$body .= "Content-Transfer-Encoding: quoted-printable\r\n\r\n";
|
||||
$body .= quoted_printable_encode($html) . "\r\n";
|
||||
$body .= "--{$boundary}--\r\n";
|
||||
|
||||
// Dot-stuff the body
|
||||
$body = preg_replace('/^\.$/m', '..', $body);
|
||||
|
||||
fwrite($sock, $headers . "\r\n" . $body . "\r\n.\r\n");
|
||||
_smtp_expect($sock, 250);
|
||||
|
||||
_smtp_cmd($sock, 'QUIT');
|
||||
fclose($sock);
|
||||
return true;
|
||||
}
|
||||
|
||||
function _smtp_cmd($sock, string $cmd): void {
|
||||
fwrite($sock, $cmd . "\r\n");
|
||||
}
|
||||
|
||||
function _smtp_read_all($sock): string {
|
||||
$data = '';
|
||||
while (!feof($sock)) {
|
||||
$line = fgets($sock, 512);
|
||||
if ($line === false) break;
|
||||
$data .= $line;
|
||||
// Last line of multi-line response has a space after the code
|
||||
if (strlen($line) >= 4 && $line[3] === ' ') break;
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
function _smtp_expect($sock, $codes): void {
|
||||
$response = _smtp_read_all($sock);
|
||||
$code = (int)substr(trim($response), 0, 3);
|
||||
$expected = (array)$codes;
|
||||
if (!in_array($code, $expected, true)) {
|
||||
throw new RuntimeException("SMTP unexpected response {$code}: " . trim($response));
|
||||
}
|
||||
}
|
||||
|
||||
function _mail_encode_name(string $name): string {
|
||||
if ($name === '') return '';
|
||||
// RFC 2047 encode if needed
|
||||
if (preg_match('/[^\x20-\x7E]/', $name) || strpbrk($name, '"\\,;<>@') !== false) {
|
||||
return '=?UTF-8?B?' . base64_encode($name) . '?=';
|
||||
}
|
||||
return $name;
|
||||
}
|
||||
Reference in New Issue
Block a user