sites = $sites; $this->mailer = $mailer; $this->log = $log; $this->recipients = $recipients ? $recipients : new Recipients(); $this->runs = $runs ? $runs : new RunRepository(); $this->deliveries = $deliveries ? $deliveries : new DeliveryRepository(); $this->locks = new SendLocks(); $this->schedule = new SendSchedule( $log ); $this->sender = new RecipientSender( $mailer, $this->runs, $this->deliveries, $log, $this->recipients, $this->locks ); $this->processor = new RunProcessor( $sites, $mailer, $log, $this->recipients, $this->runs, $this->deliveries, $notifier, $this->sender ); } /** * Runs storage. * * @return RunRepository */ public function runs() { return $this->runs; } /** * Deliveries storage. * * @return DeliveryRepository */ public function deliveries() { return $this->deliveries; } /** * Mailer. * * @return ReportMailer */ public function mailer() { return $this->mailer; } /** * Hourly sweep: starts every report whose time has passed, makes sure each * active schedule has its cron event, retries failed recipients, re-checks * held reports and finishes reports a stopped job left half-done. * * @param int|null $now Unix timestamp; defaults to now. * @return int Reports started. */ public function dispatch_due( $now = null ) { $now = null === $now ? time() : (int) $now; $sent = 0; Schema::ensure(); foreach ( $this->sites->all() as $site ) { $status = $this->log->get( $site->id ); if ( ! Schedule::is_active( $site->email ) ) { if ( $status['next'] ) { $this->forget( $site->id ); } continue; } if ( ! $status['next'] ) { $this->reschedule( $site, $now ); continue; } if ( $status['next'] > $now ) { $this->schedule->ensure_event( $site->id, $status['next'] ); continue; } if ( $sent >= self::BATCH ) { continue; // The rest go out on the next sweep. } try { if ( $this->send( $site, $now ) ) { ++$sent; } } catch ( \Throwable $e ) { // One site's failure must not stop the other sites or the retry queue. $this->log_error( $site->id, $e ); } } $this->work_queue( $now ); return $sent; } /** * Retries, held reports and interrupted runs. * * @param int $now Unix time. * @return void */ public function work_queue( $now ) { if ( ! Schema::ready() ) { return; } $this->deliveries->fail_stuck( $now ); $ids = array_unique( array_merge( $this->deliveries->due_runs( $now ), $this->runs->needing_work( $now ) ) ); foreach ( array_slice( $ids, 0, self::BATCH ) as $id ) { $run = $this->runs->find( $id ); $site = $run ? $this->sites->find( $run['site_id'] ) : null; if ( ! $run ) { continue; } if ( ! $site ) { $this->runs->update( $id, array( 'status' => RunRepository::CANCELLED ) ); continue; } if ( ! $this->locks->lock( $site->id ) ) { continue; } try { $this->process( $id, $now ); } catch ( \Throwable $e ) { $this->log_error( $site->id, $e ); } finally { $this->locks->unlock( $site->id ); } } } /** * Handler for a site's single cron event. * * @param string $site_id Site ID. * @param int|null $now Unix timestamp; defaults to now. * @return bool Whether a report was started. */ public function send_site( $site_id, $now = null ) { $now = null === $now ? time() : (int) $now; $site = $this->sites->find( (string) $site_id ); if ( ! $site || ! Schedule::is_active( $site->email ) ) { $this->forget( (string) $site_id ); return false; } $status = $this->log->get( $site->id ); // Cron can fire a little early; anything more means the schedule changed since. if ( $status['next'] && $status['next'] > $now + MINUTE_IN_SECONDS ) { $this->schedule->ensure_event( $site->id, $status['next'] ); return false; } return $this->send( $site, $now ); } /** * Starts one site's scheduled report and books the next one. * * @param Profile $site Site. * @param int $now Unix timestamp. * @return bool Whether this call started it. */ private function send( Profile $site, $now ) { if ( ! $this->locks->lock( $site->id ) ) { return false; } try { return $this->send_locked( $site, $now ); } finally { $this->locks->unlock( $site->id ); } } /** * The body of send(), run while holding the site's lock. * * @param Profile $site Site. * @param int $now Unix timestamp. * @return bool Whether this call started it. */ private function send_locked( Profile $site, $now ) { $this->log->refresh(); $status = $this->log->get( $site->id ); if ( $status['next'] > $now + MINUTE_IN_SECONDS ) { $this->schedule->ensure_event( $site->id, $status['next'] ); return false; } // The slot this send is for. Every-2-weeks schedules count from it, so a late send doesn't push the next one back. $slot = Schedule::latest_slot( $site->email, $status['next'] ? (int) $status['next'] : $now, $now ); $next = Schedule::next_run( $site->email, $now, $slot ); // Book the next send before doing the work: an error below must never stall the schedule. $this->log->update( $site->id, array( 'next' => $next, 'retries' => 0, ) ); $this->schedule->schedule_event( $site->id, $next ); if ( ! Schema::ensure() ) { // Without history tables (very rare: no CREATE permission), send without recording history. $this->sender->send_without_history( $site, $now, $slot ); return true; } $period = Schedule::period( $site->email, wp_date( 'Y-m-d', $slot ) ); $run = $this->runs->open( array( 'run_key' => $site->id . '|' . $slot, 'site_id' => $site->id, 'kind' => RunRepository::KIND_SCHEDULED, 'slot' => (int) $slot, 'period_start' => $period['s'], 'period_end' => $period['e'], 'compare_start' => $period['ps'], 'compare_end' => $period['pe'], ) ); if ( $run ) { $this->runs->update( $run['id'], array( 'meta' => array_merge( (array) $run['meta'], array( 'frequency' => $site->email['frequency'], 'compare' => $site->email['compare'], ) ), ) ); $this->process( $run['id'], $now ); } return true; } /** * Moves a run forward: build and check it, wait for review, hold it, or send. * * Safe to call again at any point; it carries on where the run is. * * @param int $run_id Run ID. * @param int $now Unix time. * @param array $opts force (send even if blocked), approved (a reviewer approved it), rebuild (fetch the numbers again). * @return array|null The run afterwards. */ public function process( $run_id, $now = null, array $opts = array() ) { return $this->processor->process( $run_id, $now, $opts ); } /** * Records an unexpected error for a site without exposing PHP internals. * * @param string $site_id Site ID. * @param \Throwable $error Caught error. * @return void */ private function log_error( $site_id, \Throwable $error ) { /* translators: %s: error message. */ $this->log->update( $site_id, array( 'last_error' => sprintf( __( 'The report couldn\'t be sent: %s', 'turtlereport' ), ErrorText::from( $error ) ) ) ); } /** * Approves a run waiting for review (or held) and sends it now. * * @param int $run_id Run ID. * @param int $user_id Approving user. * @return array|null Run afterwards. */ public function approve( $run_id, $user_id ) { $run = $this->runs->find( $run_id ); if ( ! $run || ! in_array( $run['status'], array( RunRepository::REVIEW, RunRepository::HELD, RunRepository::PREPARING ), true ) ) { return $run; } $this->runs->update( $run['id'], array( 'reviewed_by' => (int) $user_id, 'reviewed_at' => time(), 'next_check' => 0, ) ); return $this->locks->locked( $run['site_id'], function () use ( $run ) { return $this->process( $run['id'], null, array( 'approved' => true, 'force' => true, ) ); } ); } /** * Sends failed recipients of a run again. Accepted recipients are left alone. * * @param int $run_id Run ID. * @param int|null $delivery_id One recipient row, or null for all failed ones. * @return array|null Run afterwards. */ public function resend_failed( $run_id, $delivery_id = null ) { $run = $this->runs->find( $run_id ); if ( ! $run ) { return null; } $this->deliveries->requeue_failed( $run['id'], $delivery_id ); return $this->locks->locked( $run['site_id'], function () use ( $run ) { return $this->process( $run['id'], null, array( 'force' => true ) ); } ); } /** * Builds a held or reviewed run's numbers again from Google. * * @param int $run_id Run ID. * @return array|null Run afterwards. */ public function rebuild( $run_id ) { $run = $this->runs->find( $run_id ); if ( ! $run || $this->deliveries->for_run( $run['id'] ) ) { return $run; } $site = $this->sites->find( $run['site_id'] ); if ( ! $site ) { return $run; } $prep = $this->mailer->builder()->prepare( $site, RunRepository::period( $run ) ); $fields = array( 'snapshot' => array( 'data' => $prep['data'], 'full' => $prep['full'], ), 'readiness' => $prep['readiness']['level'], 'checks' => $prep['readiness'], ); if ( empty( $run['meta']['insights_edited'] ) ) { $fields['insights'] = $prep['insights']; } $this->runs->update( $run['id'], $fields ); return $this->runs->find( $run['id'] ); } /** * Skips a run that hasn't gone out. * * @param int $run_id Run ID. * @return bool */ public function cancel( $run_id ) { return $this->runs->transition( (int) $run_id, array( RunRepository::PREPARING, RunRepository::REVIEW, RunRepository::HELD ), RunRepository::CANCELLED ); } /** * Sends a site's report now, marked as a test, without touching the * schedule. Recorded in the delivery history as a test. * * @param Profile $site Site. * @param string|string[] $to Email address or addresses. * @param int|null $now Unix time. * @return true|\WP_Error True only when every address was accepted. */ public function send_test( Profile $site, $to, $now = null ) { $now = null === $now ? time() : (int) $now; $to = array_values( array_filter( (array) $to, 'is_email' ) ); if ( ! $to ) { return new \WP_Error( 'turtlereport_no_recipients', __( 'The test wasn\'t sent: no valid email address was chosen.', 'turtlereport' ) ); } if ( ! Schema::ensure() ) { return $this->mailer->send( $site, $to, true ); } $period = Schedule::period( $site->email ); $run = $this->runs->open( array( 'run_key' => 'test|' . $site->id . '|' . $now . '|' . wp_generate_password( 6, false, false ), 'site_id' => $site->id, 'kind' => RunRepository::KIND_TEST, 'slot' => $now, 'period_start' => $period['s'], 'period_end' => $period['e'], 'compare_start' => $period['ps'], 'compare_end' => $period['pe'], ) ); if ( ! $run ) { return $this->mailer->send( $site, $to, true ); } $prep = $this->mailer->builder()->prepare( $site, $period ); $this->runs->update( $run['id'], array( 'snapshot' => array( 'data' => $prep['data'], 'full' => $prep['full'], ), 'readiness' => $prep['readiness']['level'], 'checks' => $prep['readiness'], 'insights' => $prep['insights'], 'status' => RunRepository::SENDING, 'meta' => array( 'frequency' => $site->email['frequency'], 'compare' => $site->email['compare'], ), ) ); $this->deliveries->ensure( $run['id'], $site->id, $to ); $this->sender->send_pending( $site, $this->runs->find( $run['id'] ), $now ); $failed = array(); foreach ( $this->deliveries->for_run( $run['id'] ) as $row ) { if ( DeliveryRepository::ACCEPTED !== $row['status'] ) { $failed[ $row['recipient'] ] = (string) $row['error']; } } return ReportMailer::result( count( $to ), $failed ); } /** * Sends a site's report to its recipients right now, outside the * schedule: the latest complete period for its interval (last month for * a monthly report). It is recorded under Email deliveries as a manual send, * with the same data checks, per-recipient results and retries as a * scheduled one, and doesn't move the next scheduled email. Review mode * doesn't apply (an administrator asked for it), but a report whose data * is broken is still held when the site holds incomplete reports. * * @param Profile $site Site. * @param int|null $now Unix time. * @return array|\WP_Error The run afterwards. */ public function send_now( Profile $site, $now = null ) { $now = null === $now ? time() : (int) $now; if ( ! $this->recipients->addresses( $site ) ) { return new \WP_Error( 'turtlereport_no_recipients', __( 'Nobody to send to: choose "Everyone with access" or add an address first.', 'turtlereport' ) ); } if ( ! Schema::ensure() ) { return new \WP_Error( 'turtlereport_no_history', __( 'The delivery history tables are missing, so the report can\'t be sent from here. Deactivate and reactivate TurtleReport.', 'turtlereport' ) ); } if ( ! $this->locks->lock( $site->id ) ) { return new \WP_Error( 'turtlereport_busy', __( 'This report is being sent right now. Check Email deliveries in a minute.', 'turtlereport' ) ); } try { return $this->send_now_locked( $site, $now ); } finally { $this->locks->unlock( $site->id ); } } /** * The body of send_now(), run while holding the site's lock. * * @param Profile $site Site. * @param int $now Unix time. * @return array|\WP_Error The run afterwards. */ private function send_now_locked( Profile $site, $now ) { $period = Schedule::period( $site->email, wp_date( 'Y-m-d', $now ) ); $run = $this->runs->open( array( 'run_key' => 'manual|' . $site->id . '|' . $now . '|' . wp_generate_password( 6, false, false ), 'site_id' => $site->id, 'kind' => RunRepository::KIND_MANUAL, 'slot' => $now, 'period_start' => $period['s'], 'period_end' => $period['e'], 'compare_start' => $period['ps'], 'compare_end' => $period['pe'], ) ); if ( ! $run ) { return new \WP_Error( 'turtlereport_no_run', __( 'The report couldn\'t be started. Try again.', 'turtlereport' ) ); } $this->runs->update( $run['id'], array( 'meta' => array( 'frequency' => $site->email['frequency'], 'compare' => $site->email['compare'], 'by' => get_current_user_id(), ), ) ); $after = $this->process( $run['id'], $now ); return $after ? $after : $run; } /** * Works out the next send time after the schedule changes, and books the cron event. * * @param Profile $site Site. * @param int|null $now Unix timestamp. * @return void */ public function reschedule( Profile $site, $now = null ) { $this->schedule->reschedule( $site, $now ); } /** * Stops a site's emails: clears its cron event and next time. * * @param string $site_id Site ID. * @return void */ public function forget( $site_id ) { $this->schedule->forget( $site_id ); } /** * Deletes old history and the numbers stored for old reports. * * @return void */ public function purge() { if ( Schema::ready() ) { $this->runs->purge( self::SNAPSHOT_DAYS, self::HISTORY_DAYS ); } } }