[codes], 'ics_event' => id]. */ function queue_email(string $to, string $name, string $subject, string $html, string $text, array $meta = []): void { q('INSERT INTO {email_queue} (to_email, to_name, subject, html, text_body, meta, send_after, created_at) VALUES (?,?,?,?,?,?,?,?)', [ $to, mb_substr($name, 0, 120), mb_substr(str_replace(["\r", "\n"], ' ', $subject), 0, 255), $html, $text, $meta ? wp_json_encode($meta) : null, now(), now(), ]); gatecraft_kick_worker(); } /** * Render a template from Settings > Email templates and queue it. * $order (optional) adds the event summary box, order lines and QR tickets. */ function queue_template_email(string $to, string $name, string $tplId, array $vars, ?string $buttonUrl, ?array $order = null, bool $withTickets = false, array $meta = []): void { // Build the email in the recipient's language: the language of the booking, else their account's, else the site default. if (empty($meta['_in_language'])) { $lang = $order['language'] ?? null; if (!language_valid($lang)) $lang = user_language_by_email($to); with_language($lang, fn() => queue_template_email($to, $name, $tplId, $vars, $buttonUrl, $order, $withTickets, ['_in_language' => 1] + $meta)); return; } unset($meta['_in_language']); $t = email_template($tplId); if (!$t['enabled']) return; $vars += ['brand_name' => setting('brand_name')]; $tl = fn(string $s) => implode("\n", array_map(fn($l) => t($l), explode("\n", $s))); $subject = trim(fill_vars($tl($t['subject']), $vars)); $heading = trim(fill_vars($tl($t['heading']), $vars)); $body = trim(preg_replace("/\n{3,}/", "\n\n", fill_vars($tl($t['body']), $vars))); $label = trim(fill_vars($tl($t['button']), $vars)); $button = $label !== '' && $buttonUrl ? [$label, $buttonUrl] : null; $paras = array_values(array_filter(array_map('trim', explode("\n", $body)), fn($l) => $l !== '')); $tickets = $order && $withTickets ? order_tickets((int)$order['id']) : []; foreach ($tickets as $tk) if ($tk['status'] === 'valid') $meta['qr'][] = $tk['code']; $pay = $order && $tplId === 'payment_pending' ? email_pay_block($order) : null; $html = i18n_html(render_partial('email/layout', [ 'pay' => $pay, 'info' => $pay ? email_event_info($order) : null, 'heading' => $heading, 'paras' => $paras, 'button' => $button, 'order' => $order, 'items' => $order ? order_items((int)$order['id']) : [], 'tickets' => array_filter($tickets, fn($tk) => $tk['status'] === 'valid'), ])); $text = $heading . "\n\n" . implode("\n\n", $paras); if ($order) { $text .= "\n\n" . $order['event_title'] . "\n" . fmt_dt($order['starts_at']) . ($order['venue_name'] ? "\n" . $order['venue_name'] : '') . "\n" . t('Reference') . ': ' . $order['reference']; } if ($pay) { $text .= "\n\nPAY " . $pay['amount'] . ' BY ' . strtoupper($pay['method']) . "\n" . t('Amount') . ': ' . $pay['amount'] . "\n" . t('Reference') . ': ' . $pay['reference'] . ($pay['deadline'] !== '' ? "\n" . t('Pay before') . ': ' . $pay['deadline'] : ''); foreach ($pay['steps'] as $i => $st) $text .= "\n" . ($i + 1) . '. ' . $st; $info = email_event_info($order); $text .= "\n\n" . t('Event details'); foreach ($info['rows'] as $r) $text .= "\n" . t($r[0]) . ': ' . $r[1]; if ($info['note'] !== '') $text .= "\n\n" . $info['note']; } if ($button) $text .= "\n\n" . $button[0] . ': ' . $button[1]; foreach ($tickets as $tk) if ($tk['status'] === 'valid') $text .= "\n" . t('Ticket') . ' ' . $tk['category_name'] . ': ' . format_code($tk['code']); $text .= "\n\n" . setting('email_footer') . (powered_by_hidden() ? '' : "\n\n" . t('Powered by Gatecraft') . (GATECRAFT_HOME_URL !== '' ? ' ' . GATECRAFT_HOME_URL : '')) . "\n"; queue_email($to, $name, $subject ?: setting('brand_name'), $html, $text, $meta); } /** Sends up to $limit queued emails. Rows are claimed atomically so parallel workers never double-send. */ function process_email_queue(int $limit = 20): int { q("UPDATE {email_queue} SET status = 'queued' WHERE status = 'sending' AND send_after < ?", [ldate('Y-m-d H:i:s', time() - 900)]); $sent = 0; $ids = q("SELECT id FROM {email_queue} WHERE status = 'queued' AND send_after <= ? ORDER BY id LIMIT " . (int)$limit, [now()])->fetchAll(Result::COLUMN); foreach ($ids as $id) { $claim = q("UPDATE {email_queue} SET status = 'sending', attempts = attempts + 1, send_after = ? WHERE id = ? AND status = 'queued'", [now(), $id]); if ($claim->rowCount() !== 1) continue; $m = q1('SELECT * FROM {email_queue} WHERE id = ?', [$id]); try { send_mail_now($m); q("UPDATE {email_queue} SET status = 'sent', sent_at = ?, last_error = NULL WHERE id = ?", [now(), $id]); $sent++; } catch (Throwable $t) { $final = (int)$m['attempts'] >= 6; q('UPDATE {email_queue} SET status = ?, last_error = ?, send_after = ? WHERE id = ?', [ $final ? 'failed' : 'queued', mb_substr($t->getMessage(), 0, 500), ldate('Y-m-d H:i:s', time() + 60 * (2 ** (int)$m['attempts'])), $id, ]); if ($final) notify_staff('mailfail', 'Email to ' . $m['to_email'] . ' failed', mb_substr($t->getMessage(), 0, 200), '/admin/emails'); } } return $sent; } function send_mail_now(array $m): void { $meta = $m['meta'] ? json_decode($m['meta'], true) : []; $fromName = setting('mail_from_name') ?: setting('brand_name'); $headers = ['Content-Type: text/html; charset=UTF-8']; $fromAddr = setting('mail_from_address'); if ($fromAddr !== '' && is_email($fromAddr)) $headers[] = 'From: ' . str_replace(['"', "\r", "\n"], '', $fromName) . ' <' . $fromAddr . '>'; $reply = setting('reply_to') ?: setting('support_email'); if ($reply !== '' && is_email($reply)) $headers[] = 'Reply-To: ' . $reply; foreach ((array)($meta['headers'] ?? []) as $h) if (preg_match('/^List-Unsubscribe(-Post)?: [^\r\n]+$/', (string)$h)) $headers[] = $h; $nameFilter = fn() => $fromName; $attach = function ($phpmailer) use ($m, $meta) { $phpmailer->AltBody = $m['text_body']; foreach ($meta['qr'] ?? [] as $code) { if (preg_match('/^[0-9A-Z]{16}$/', $code)) $phpmailer->addStringEmbeddedImage(qr_png($code, 8), 'qr-' . $code, 'ticket-' . $code . '.png', 'base64', 'image/png'); } if (!empty($meta['ics_event'])) { $ics = event_ics((int)$meta['ics_event']); if ($ics) $phpmailer->addStringAttachment($ics, 'event.ics', 'base64', 'text/calendar'); } }; $error = null; $onFail = function ($wpError) use (&$error) { $error = $wpError instanceof WP_Error ? $wpError->get_error_message() : 'Mail failed'; }; add_action('phpmailer_init', $attach); add_action('wp_mail_failed', $onFail); if ($fromAddr === '') add_filter('wp_mail_from_name', $nameFilter); try { $ok = wp_mail($m['to_name'] !== '' ? sprintf('%s <%s>', str_replace(['"', ',', '<', '>', "\r", "\n"], '', $m['to_name']), $m['to_email']) : $m['to_email'], $m['subject'], $m['html'], $headers); } finally { remove_action('phpmailer_init', $attach); remove_action('wp_mail_failed', $onFail); remove_filter('wp_mail_from_name', $nameFilter); } if (!$ok) throw new RuntimeException(esc_html($error ?: 'wp_mail returned false')); } /** * A text value escaped for iCalendar (RFC 5545, 3.3.11): backslash, semicolon and comma are escaped, line breaks become * \n, and every other control character is removed, so a value can never start a new property line. */ function ics_text(string $s): string { $s = str_replace(["\r\n", "\r"], "\n", wp_check_invalid_utf8($s, true)); $s = (string)preg_replace('/[\x00-\x09\x0B-\x1F\x7F]/u', '', $s); return str_replace(['\\', ';', ',', "\n"], ['\\\\', '\\;', '\\,', '\\n'], $s); } /** One content line, folded at 75 octets as RFC 5545 (3.1) requires, without splitting a UTF-8 character. */ function ics_line(string $line): string { $out = ''; $len = 0; foreach (preg_split('//u', $line, -1, PREG_SPLIT_NO_EMPTY) ?: [] as $ch) { $n = strlen($ch); if ($len + $n > 75) { $out .= "\r\n "; $len = 1; } $out .= $ch; $len += $n; } return $out . "\r\n"; } function event_ics(int $eventId): ?string { $ev = event_with_venue($eventId); if (!$ev) return null; $fmt = fn(string $dt) => gmdate('Ymd\THis\Z', lstrtotime($dt)); $end = $ev['ends_at'] ?: ldate('Y-m-d H:i:s', lstrtotime($ev['starts_at']) + 7200); $loc = implode(', ', array_filter([$ev['venue_name'], $ev['venue_address'], $ev['venue_city']])); $host = (string)preg_replace('/[^a-z0-9.-]/i', '', (string)wp_parse_url((string)cfg('base_url'), PHP_URL_HOST)); $url = (string)preg_replace('/[\x00-\x20\x7F]/', '', esc_url_raw(url('/e/' . $ev['slug']))); $lines = [ 'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//' . ics_text(setting('brand_name')) . '//Tickets//EN', 'CALSCALE:GREGORIAN', 'METHOD:PUBLISH', 'BEGIN:VEVENT', 'UID:event-' . (int)$ev['id'] . '@' . $host, 'DTSTAMP:' . gmdate('Ymd\THis\Z'), 'DTSTART:' . $fmt($ev['starts_at']), 'DTEND:' . $fmt($end), 'SUMMARY:' . ics_text((string)$ev['title']), ]; if ($loc !== '') $lines[] = 'LOCATION:' . ics_text($loc); if ($url !== '') $lines[] = 'URL:' . $url; array_push($lines, 'END:VEVENT', 'END:VCALENDAR'); return implode('', array_map(__NAMESPACE__ . '\\ics_line', $lines)); } function order_vars(array $o, string $note = ''): array { return [ 'buyer_name' => $o['buyer_name'], 'event_title' => $o['event_title'], 'event_date' => fmt_dt($o['starts_at']), 'venue' => $o['venue_name'] ?: t('the venue'), 'doors_time' => $o['doors_at'] ? fmt_time($o['doors_at']) : fmt_time($o['starts_at']), 'booking_reference' => $o['reference'], 'amount' => (int)$o['total'] > 0 ? money((int)$o['total']) : t('Free'), 'pay_deadline' => $o['expires_at'] ? fmt_dt($o['expires_at']) : '', 'staff_note' => $note !== '' ? $note : ($o['decision_note'] ? t('Note from the organiser:') . ' ' . $o['decision_note'] : ''), 'payment_method' => payment_label($o), 'payment_instructions' => order_pay_instructions($o), 'bank_instructions' => order_pay_instructions($o), // older templates used this name 'event_note' => (string)qv('SELECT email_note FROM {events} WHERE id = ?', [$o['event_id']]), ]; } /** Queue the buyer email for an order event. $kind is a template id. */ function email_order(int $orderId, string $kind, array $extra = []): void { $o = get_order($orderId); if (!$o || !isset(email_templates()[$kind])) return; if (trim((string)$o['buyer_email']) === '') return; // a door sale or guest-list booking without an email address if ($kind === 'expired' && !setting_on('expired_email')) return; $link = order_link($o); $button = match ($kind) { 'approved' => $link, // the booking page, where the Pay button starts the card payment 'expired' => url('/e/' . $o['event_slug']), 'confirm_email' => free_confirm_url($o), 'konbini_pending' => str_starts_with((string)$o['pay_instructions'], 'https://') ? (string)$o['pay_instructions'] : $link, default => $link, }; $meta = $kind === 'tickets' ? ['ics_event' => (int)$o['event_id']] : []; queue_template_email($o['buyer_email'], $o['buyer_name'], $kind, $extra + order_vars($o), $button, $o, in_array($kind, ['tickets', 'reminder'], true), $meta); } /** Email every confirmed buyer of an event once (deduplicated by order). */ function email_event_buyers(int $eventId, string $kind, array $vars = []): int { $n = 0; foreach (q("SELECT id FROM {orders} WHERE event_id = ? AND status = 'paid' AND buyer_email <> ''", [$eventId])->fetchAll(Result::COLUMN) as $oid) { email_order((int)$oid, $kind, $vars); $n++; } return $n; } function send_daily_summary(): bool { if (!setting_on('daily_summary_enabled')) return false; $today = ldate('Y-m-d'); if (setting('daily_summary_last') === $today || ldate('H:i') < setting('daily_summary_time')) return false; setting_save(['daily_summary_last' => $today]); $y = ldate('Y-m-d', lstrtotime('-1 day')); $r = q1("SELECT COUNT(*) AS orders, COALESCE(SUM(total),0) AS revenue FROM {orders} WHERE status = 'paid' AND paid_at BETWEEN ? AND ?", [$y . ' 00:00:00', $y . ' 23:59:59']); $t = (int)qv("SELECT COUNT(*) FROM {tickets} tk JOIN {orders} o ON o.id = tk.order_id WHERE o.paid_at BETWEEN ? AND ?", [$y . ' 00:00:00', $y . ' 23:59:59']); $pending = (int)qv("SELECT COUNT(*) FROM {orders} WHERE status = 'awaiting_approval' AND expires_at > ?", [now()]); $body = 'Yesterday (' . fmt_date($y) . '): ' . plural((int)$r['orders'], 'paid booking') . ', ' . plural($t, 'ticket') . ', revenue ' . money((int)$r['revenue']) . '. Requests awaiting approval: ' . $pending . '.'; foreach (alert_recipients() as $to) queue_template_email($to, '', 'staff_alert', ['alert_title' => 'Daily sales summary', 'alert_body' => $body], url('/admin')); return true; } /** What the buyer must do to pay, ready for the email: amount, reference, deadline and numbered steps. */ function email_pay_block(array $o): array { $steps = array_values(array_filter(array_map('trim', preg_split('/\R/', order_pay_instructions($o))), fn($l) => $l !== '')); $steps[] = 'We check your payment and email your QR tickets.'; return ['amount' => money((int)$o['total']), 'reference' => (string)$o['reference'], 'method' => payment_label($o), 'deadline' => $o['expires_at'] ? fmt_dt($o['expires_at']) : '', 'steps' => $steps]; } /** Event facts for the email: when, doors, where, map and page links, and the organiser's note. */ function email_event_info(array $o): array { $rows = [['When', fmt_dt($o['starts_at'])]]; if ($o['doors_at']) $rows[] = ['Doors open', fmt_time($o['doors_at'])]; if ($o['venue_name']) $rows[] = ['Where', implode(', ', array_filter([$o['venue_name'], $o['venue_address'], $o['venue_city']]))]; if (!empty($o['venue_map']) && preg_match('#^https?://#i', (string)$o['venue_map'])) $rows[] = ['Map', (string)$o['venue_map']]; $rows[] = ['Event page', url('/e/' . $o['event_slug'])]; return ['rows' => $rows, 'note' => trim((string)qv('SELECT email_note FROM {events} WHERE id = ?', [$o['event_id']]))]; }