query( $wpdb->prepare( "INSERT IGNORE INTO {$table} (run_id, site_id, recipient, status, attempts, last_attempt, next_attempt, error, created, updated) VALUES (%d, %s, %s, %s, 0, 0, 0, '', %d, %d)", (int) $run_id, (string) $site_id, $email, self::PENDING, $now, $now ) ); } } /** * Every recipient row of a run. * * @param int $run_id Run ID. * @return array[] */ public function for_run( $run_id ) { global $wpdb; $table = Schema::deliveries(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Custom table. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} WHERE run_id = %d ORDER BY recipient ASC", (int) $run_id ), ARRAY_A ); return array_map( array( __CLASS__, 'decode' ), is_array( $rows ) ? $rows : array() ); } /** * Claims a row for sending. * * @param int $id Row ID. * @param int $now Unix time. * @return int Claim stamp to pass to record(), or 0 when this process may not send it. */ public function claim( $id, $now ) { global $wpdb; $table = Schema::deliveries(); $stamp = max( time(), (int) $now ); // The claim time is real time, so a sweep that started long ago cannot make // fail_stuck() treat a row that is being sent right now as abandoned. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Custom table. $n = $wpdb->query( $wpdb->prepare( "UPDATE {$table} SET status = %s, updated = %d WHERE id = %d AND status IN (%s, %s) AND next_attempt <= %d", self::SENDING, $stamp, (int) $id, self::PENDING, self::RETRYING, (int) $now ) ); return 1 === (int) $n ? $stamp : 0; } /** * Records the result of an attempt. * * @param array $row Row (as claimed). * @param bool $ok Whether wp_mail() accepted it. * @param string $error Error message when not. * @param int $now Unix time. * @param bool|null $retry Whether to retry; null decides from the attempt count. * @param int|null $claimed Stamp returned by claim(); defaults to $now. * @return string New status. * @throws \RuntimeException When the claim was replaced or the result cannot be stored. */ public function record( array $row, $ok, $error, $now, $retry = null, $claimed = null ) { global $wpdb; $attempts = (int) $row['attempts'] + 1; if ( $ok ) { $status = self::ACCEPTED; $next = 0; $error = ''; } else { $retry = null === $retry ? $attempts < self::MAX_ATTEMPTS : (bool) $retry; $status = $retry ? self::RETRYING : self::FAILED; $next = $retry ? (int) $now + self::backoff( $attempts ) : 0; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. $saved = $wpdb->update( Schema::deliveries(), array( 'status' => $status, 'attempts' => $attempts, 'last_attempt' => (int) $now, 'next_attempt' => $next, 'error' => substr( (string) $error, 0, 1000 ), 'updated' => (int) $now, ), array( 'id' => (int) $row['id'], 'status' => self::SENDING, 'updated' => null === $claimed ? (int) $now : (int) $claimed, ) ); if ( 1 !== $saved ) { throw new \RuntimeException( __( 'The mail result could not be recorded. Check delivery history before resending.', 'turtlereport' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain domain exception, not HTML output; preserve message bytes and escape at the response boundary. } return $status; } /** * Wait before the next automatic attempt: 1 hour, then 3 hours. * * @param int $attempts Attempts made. * @return int Seconds. */ public static function backoff( $attempts ) { return 1 === (int) $attempts ? HOUR_IN_SECONDS : 3 * HOUR_IN_SECONDS; } /** * Puts failed recipients of a run back in the queue (manual resend). * Accepted recipients are never touched. * * @param int $run_id Run ID. * @param int|null $id One row only, or null for every failed row. * @return int Rows queued. */ public function requeue_failed( $run_id, $id = null ) { global $wpdb; $table = Schema::deliveries(); $sql = "UPDATE {$table} SET status = %s, next_attempt = 0, updated = %d WHERE run_id = %d AND status IN (%s, %s)"; $args = array( self::PENDING, time(), (int) $run_id, self::FAILED, self::RETRYING ); if ( null !== $id ) { $sql .= ' AND id = %d'; $args[] = (int) $id; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- Custom table. return (int) $wpdb->query( $wpdb->prepare( $sql, $args ) ); } /** * Run IDs with recipients due for a retry. * * @param int $now Unix time. * @return int[] */ public function due_runs( $now ) { global $wpdb; $table = Schema::deliveries(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Custom table. $ids = $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT run_id FROM {$table} WHERE status = %s AND next_attempt <= %d LIMIT 20", self::RETRYING, (int) $now ) ); return array_map( 'intval', is_array( $ids ) ? $ids : array() ); } /** * Marks rows left "sending" by a process that died as failed. * * Whether that email went out is unknown, so it isn't retried * automatically (that could send it twice); an administrator can resend. * * @param int $now Unix time. * @return int Rows changed. */ public function fail_stuck( $now ) { global $wpdb; $table = Schema::deliveries(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Custom table. return (int) $wpdb->query( $wpdb->prepare( "UPDATE {$table} SET status = %s, error = %s, updated = %d WHERE status = %s AND updated < %d", self::FAILED, __( 'The sending job stopped before it finished, so it isn\'t known whether this email went out. Resend it if the recipient didn\'t get it.', 'turtlereport' ), (int) $now, self::SENDING, (int) $now - 15 * MINUTE_IN_SECONDS ) ); } /** * Counts per status for a run. * * @param array[] $rows Rows of one run. * @return array */ public static function tally( array $rows ) { $out = array_fill_keys( array( self::PENDING, self::SENDING, self::ACCEPTED, self::RETRYING, self::FAILED ), 0 ); foreach ( $rows as $row ) { if ( isset( $out[ $row['status'] ] ) ) { ++$out[ $row['status'] ]; } } return $out; } /** * Run status from its recipients' statuses. * * @param array $tally Result of tally(). * @return string RunRepository status. */ public static function run_status( array $tally ) { $total = array_sum( $tally ); if ( ! $total ) { return RunRepository::FAILED; } if ( $tally[ self::PENDING ] || $tally[ self::SENDING ] ) { return RunRepository::SENDING; } if ( $tally[ self::RETRYING ] ) { return RunRepository::RETRYING; } if ( $tally[ self::ACCEPTED ] === $total ) { return RunRepository::SENT; } return $tally[ self::ACCEPTED ] ? RunRepository::PARTIAL : RunRepository::FAILED; } /** * Human label for a recipient status. * * @param string $status Status. * @return string */ public static function label( $status ) { $labels = array( self::PENDING => __( 'Waiting', 'turtlereport' ), self::SENDING => __( 'Sending', 'turtlereport' ), self::ACCEPTED => __( 'Accepted by mail system', 'turtlereport' ), self::RETRYING => __( 'Retrying', 'turtlereport' ), self::FAILED => __( 'Failed', 'turtlereport' ), ); return isset( $labels[ $status ] ) ? $labels[ $status ] : (string) $status; } /** * Decodes a row. * * @param array $row Row. * @return array */ public static function decode( $row ) { foreach ( array( 'id', 'run_id', 'attempts', 'last_attempt', 'next_attempt', 'created', 'updated' ) as $col ) { if ( isset( $row[ $col ] ) ) { $row[ $col ] = (int) $row[ $col ]; } } return $row; } }