Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
97.42% |
755 / 775 |
|
68.18% |
30 / 44 |
CRAP | |
0.00% |
0 / 1 |
| REST_Controller | |
97.67% |
755 / 773 |
|
68.18% |
30 / 44 |
239 | |
0.00% |
0 / 1 |
| register_routes | |
100.00% |
163 / 163 |
|
100.00% |
1 / 1 |
4 | |||
| can_manage | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| list_arg | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
6 | |||
| to_list | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
4 | |||
| get_status | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| status_data | |
100.00% |
30 / 30 |
|
100.00% |
1 / 1 |
5 | |||
| trimmed_before | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
4 | |||
| sender_avatar | |
92.31% |
12 / 13 |
|
0.00% |
0 / 1 |
9.04 | |||
| site_icon_url | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
3 | |||
| suggested_domain | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| known_spf | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
| enable_blockers | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
6 | |||
| setup | |
88.24% |
15 / 17 |
|
0.00% |
0 / 1 |
5.04 | |||
| check_dns | |
92.86% |
13 / 14 |
|
0.00% |
0 / 1 |
5.01 | |||
| note_dns_check | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
1 | |||
| update_settings | |
97.92% |
47 / 48 |
|
0.00% |
0 / 1 |
28 | |||
| setting_name | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
1 | |||
| apply_debug_change | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
9.24 | |||
| send_test | |
97.56% |
40 / 41 |
|
0.00% |
0 / 1 |
14 | |||
| get_usage | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
| usage_data | |
100.00% |
20 / 20 |
|
100.00% |
1 / 1 |
5 | |||
| get_stats | |
94.12% |
16 / 17 |
|
0.00% |
0 / 1 |
7.01 | |||
| stats_data | |
98.55% |
68 / 69 |
|
0.00% |
0 / 1 |
26 | |||
| top_origins | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
4 | |||
| site_moment | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
| get_log | |
94.44% |
17 / 18 |
|
0.00% |
0 / 1 |
4.00 | |||
| log_window | |
90.91% |
10 / 11 |
|
0.00% |
0 / 1 |
5.02 | |||
| log_row | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
4 | |||
| get_log_origins | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
2 | |||
| get_log_summary | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| avatar_url | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| avatar_for_email | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
5 | |||
| first_recipient | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
| get_log_entry | |
97.37% |
37 / 38 |
|
0.00% |
0 / 1 |
13 | |||
| support_text | |
97.22% |
35 / 36 |
|
0.00% |
0 / 1 |
18 | |||
| resend_log_entry | |
98.08% |
51 / 52 |
|
0.00% |
0 / 1 |
7 | |||
| resend_refused | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
3 | |||
| delete_log_entry | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| delete_log_entries | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
| entry_not_found | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| reset | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
5 | |||
| sanitize_domain | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| not_set_up | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| error_response | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
4 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * REST routes for the Send dashboard. |
| 4 | * |
| 5 | * @package automattic/jetpack-send |
| 6 | */ |
| 7 | |
| 8 | namespace Automattic\Jetpack\Send; |
| 9 | |
| 10 | use WP_Error; |
| 11 | use WP_REST_Request; |
| 12 | use WP_REST_Server; |
| 13 | |
| 14 | if ( ! defined( 'ABSPATH' ) ) { |
| 15 | exit( 0 ); |
| 16 | } |
| 17 | |
| 18 | /** |
| 19 | * `jetpack-send/v1` routes; all require `manage_options`. |
| 20 | */ |
| 21 | class REST_Controller { |
| 22 | |
| 23 | const API_NAMESPACE = 'jetpack-send/v1'; |
| 24 | const STATUS_PATH = '/jetpack-send/v1/status'; |
| 25 | |
| 26 | /** |
| 27 | * Transient that throttles POST /check-dns to one mail-service check per window. |
| 28 | * |
| 29 | * @var string |
| 30 | */ |
| 31 | const CHECK_DNS_THROTTLE = 'jetpack_send_check_dns'; |
| 32 | |
| 33 | /** |
| 34 | * Seconds between mail-service checks from POST /check-dns. |
| 35 | * |
| 36 | * @var int |
| 37 | */ |
| 38 | const CHECK_DNS_WINDOW = 60; |
| 39 | |
| 40 | /** |
| 41 | * The widest `days` or `range` filter GET /log takes, about a hundred years. |
| 42 | * |
| 43 | * @var int |
| 44 | */ |
| 45 | const MAX_LOG_DAYS = 36500; |
| 46 | |
| 47 | /** |
| 48 | * The longest custom range GET /stats takes, in days. |
| 49 | * |
| 50 | * @var int |
| 51 | */ |
| 52 | const MAX_STATS_DAYS = 365; |
| 53 | |
| 54 | /** |
| 55 | * How the Source filter's values look: a kind of WordPress email, a plugin's folder or a theme's slug. |
| 56 | * |
| 57 | * @var string |
| 58 | */ |
| 59 | const ORIGIN_PATTERN = '/^(core(:[a-z_]+)?|plugin:[A-Za-z0-9._-]+|theme:[A-Za-z0-9._-]+)$/'; |
| 60 | |
| 61 | /** |
| 62 | * The most rows POST /log/delete takes at once: one page of GET /log at its largest. |
| 63 | * |
| 64 | * @var int |
| 65 | */ |
| 66 | const MAX_DELETE_IDS = 100; |
| 67 | |
| 68 | /** |
| 69 | * Avatar size asked for, in pixels: three times the largest round avatar on the page. |
| 70 | * |
| 71 | * @var int |
| 72 | */ |
| 73 | const AVATAR_SIZE = 120; |
| 74 | |
| 75 | /** |
| 76 | * Register the routes. |
| 77 | * |
| 78 | * @return void |
| 79 | */ |
| 80 | public static function register_routes() { |
| 81 | $id_arg = array( |
| 82 | 'id' => array( |
| 83 | 'type' => 'integer', |
| 84 | 'minimum' => 1, |
| 85 | ), |
| 86 | ); |
| 87 | $day = array( |
| 88 | 'type' => 'string', |
| 89 | 'pattern' => '^\\d{4}-\\d{2}-\\d{2}$', |
| 90 | ); |
| 91 | $day_args = array( |
| 92 | 'start' => $day, |
| 93 | 'end' => $day, |
| 94 | ); |
| 95 | $routes = array( |
| 96 | '/status' => array( WP_REST_Server::READABLE, 'get_status', array() ), |
| 97 | '/setup' => array( |
| 98 | WP_REST_Server::CREATABLE, |
| 99 | 'setup', |
| 100 | array( |
| 101 | 'domain' => array( |
| 102 | 'type' => 'string', |
| 103 | 'required' => true, |
| 104 | ), |
| 105 | ), |
| 106 | ), |
| 107 | '/check-dns' => array( WP_REST_Server::CREATABLE, 'check_dns', array() ), |
| 108 | '/settings' => array( |
| 109 | WP_REST_Server::CREATABLE, |
| 110 | 'update_settings', |
| 111 | array( |
| 112 | 'enabled' => array( 'type' => 'boolean' ), |
| 113 | 'fallback' => array( 'type' => 'boolean' ), |
| 114 | 'auto_enable' => array( 'type' => 'boolean' ), |
| 115 | 'from_name' => array( 'type' => 'string' ), |
| 116 | 'from_local_part' => array( 'type' => 'string' ), |
| 117 | 'reply_to' => array( 'type' => 'string' ), |
| 118 | 'onboarding' => array( 'type' => 'object' ), |
| 119 | 'log_retention_days' => array( |
| 120 | 'type' => 'integer', |
| 121 | 'enum' => Settings::RETENTION_DAYS, |
| 122 | ), |
| 123 | 'debug_log' => array( 'type' => 'boolean' ), |
| 124 | 'debug_log_days' => array( |
| 125 | 'type' => 'integer', |
| 126 | 'enum' => Settings::DEBUG_DAYS, |
| 127 | ), |
| 128 | ), |
| 129 | ), |
| 130 | '/test' => array( |
| 131 | WP_REST_Server::CREATABLE, |
| 132 | 'send_test', |
| 133 | array( |
| 134 | 'to' => array( 'type' => 'string' ), |
| 135 | ), |
| 136 | ), |
| 137 | '/usage' => array( WP_REST_Server::READABLE, 'get_usage', array() ), |
| 138 | '/stats' => array( |
| 139 | WP_REST_Server::READABLE, |
| 140 | 'get_stats', |
| 141 | array( |
| 142 | 'range' => array( |
| 143 | 'type' => 'integer', |
| 144 | 'enum' => Period::PRESETS, |
| 145 | 'default' => 90, |
| 146 | ), |
| 147 | ) + $day_args, |
| 148 | ), |
| 149 | '/log' => array( |
| 150 | WP_REST_Server::READABLE, |
| 151 | 'get_log', |
| 152 | array( |
| 153 | 'page' => array( |
| 154 | 'type' => 'integer', |
| 155 | 'minimum' => 1, |
| 156 | 'default' => 1, |
| 157 | ), |
| 158 | 'per_page' => array( |
| 159 | 'type' => 'integer', |
| 160 | 'minimum' => 1, |
| 161 | 'maximum' => 100, |
| 162 | 'default' => 20, |
| 163 | ), |
| 164 | 'search' => array( |
| 165 | 'type' => 'string', |
| 166 | 'default' => '', |
| 167 | ), |
| 168 | // Lists come as arrays, comma strings or one value; each matches any of its values. |
| 169 | 'status' => self::list_arg( array( Email_Log::STATUS_SENT, Email_Log::STATUS_FALLBACK, Email_Log::STATUS_FAILED ) ), |
| 170 | 'origin' => self::list_arg( array(), self::ORIGIN_PATTERN ), |
| 171 | 'source' => self::list_arg( array( 'wp_mail', 'test', 'resend' ) ), |
| 172 | // 1 is the last 24 hours, N the last N site-local days, counted as GET /stats counts them. |
| 173 | 'range' => array( |
| 174 | 'type' => 'integer', |
| 175 | 'minimum' => 1, |
| 176 | 'maximum' => self::MAX_LOG_DAYS, |
| 177 | ), |
| 178 | // Older links: 1 is today, N the same as `range`; 0 for any date. |
| 179 | 'days' => array( |
| 180 | 'type' => 'integer', |
| 181 | 'minimum' => 0, |
| 182 | 'maximum' => self::MAX_LOG_DAYS, |
| 183 | 'default' => 0, |
| 184 | ), |
| 185 | ) + $day_args, |
| 186 | ), |
| 187 | '/log/origins' => array( WP_REST_Server::READABLE, 'get_log_origins', array() ), |
| 188 | '/log/summary' => array( WP_REST_Server::READABLE, 'get_log_summary', array() ), |
| 189 | '/log/(?P<id>\d+)' => array( |
| 190 | array( WP_REST_Server::READABLE, 'get_log_entry', $id_arg ), |
| 191 | array( WP_REST_Server::DELETABLE, 'delete_log_entry', $id_arg ), |
| 192 | ), |
| 193 | '/log/(?P<id>\d+)/resend' => array( |
| 194 | WP_REST_Server::CREATABLE, |
| 195 | 'resend_log_entry', |
| 196 | $id_arg + array( |
| 197 | 'to' => array( |
| 198 | 'type' => 'array', |
| 199 | 'required' => true, |
| 200 | 'minItems' => 1, |
| 201 | 'maxItems' => 100, |
| 202 | 'items' => array( 'type' => 'string' ), |
| 203 | ), |
| 204 | 'cc' => array( |
| 205 | 'type' => 'array', |
| 206 | 'default' => array(), |
| 207 | 'maxItems' => 100, |
| 208 | 'items' => array( 'type' => 'string' ), |
| 209 | ), |
| 210 | 'bcc' => array( |
| 211 | 'type' => 'array', |
| 212 | 'default' => array(), |
| 213 | 'maxItems' => 100, |
| 214 | 'items' => array( 'type' => 'string' ), |
| 215 | ), |
| 216 | ), |
| 217 | ), |
| 218 | '/log/delete' => array( |
| 219 | WP_REST_Server::CREATABLE, |
| 220 | 'delete_log_entries', |
| 221 | array( |
| 222 | 'ids' => array( |
| 223 | 'type' => 'array', |
| 224 | 'required' => true, |
| 225 | 'minItems' => 1, |
| 226 | 'maxItems' => self::MAX_DELETE_IDS, |
| 227 | 'items' => array( |
| 228 | 'type' => 'integer', |
| 229 | 'minimum' => 1, |
| 230 | ), |
| 231 | ), |
| 232 | ), |
| 233 | ), |
| 234 | '/reset' => array( WP_REST_Server::CREATABLE, 'reset', array() ), |
| 235 | ); |
| 236 | |
| 237 | foreach ( $routes as $route => $definition ) { |
| 238 | // A route with more than one method lists one definition per method. |
| 239 | $endpoints = array(); |
| 240 | foreach ( is_array( $definition[0] ) ? $definition : array( $definition ) as $endpoint ) { |
| 241 | list( $methods, $callback, $args ) = $endpoint; |
| 242 | $endpoints[] = array( |
| 243 | 'methods' => $methods, |
| 244 | 'callback' => array( __CLASS__, $callback ), |
| 245 | 'permission_callback' => array( __CLASS__, 'can_manage' ), |
| 246 | 'args' => $args, |
| 247 | ); |
| 248 | } |
| 249 | register_rest_route( self::API_NAMESPACE, $route, $endpoints ); |
| 250 | } |
| 251 | } |
| 252 | |
| 253 | /** |
| 254 | * Permission check. |
| 255 | * |
| 256 | * @return bool |
| 257 | */ |
| 258 | public static function can_manage() { |
| 259 | return current_user_can( 'manage_options' ); |
| 260 | } |
| 261 | |
| 262 | /** |
| 263 | * A list argument: an array (`status[0]=a`), a comma string (`status=a,b`) or one value, as a clean list. |
| 264 | * Older clients send '' for any. |
| 265 | * |
| 266 | * @since $$next-version$$ |
| 267 | * |
| 268 | * @param string[] $allowed Values allowed; empty for any that match the pattern. |
| 269 | * @param string $pattern Regular expression each value must match; '' for none. |
| 270 | * @return array The argument's definition. |
| 271 | */ |
| 272 | public static function list_arg( array $allowed = array(), $pattern = '' ) { |
| 273 | return array( |
| 274 | 'default' => array(), |
| 275 | 'validate_callback' => static function ( $value ) use ( $allowed, $pattern ) { |
| 276 | foreach ( self::to_list( $value ) as $item ) { |
| 277 | if ( ( $allowed && ! in_array( $item, $allowed, true ) ) || ( '' !== $pattern && ! preg_match( $pattern, $item ) ) ) { |
| 278 | /* translators: %s: a filter value. */ |
| 279 | return new WP_Error( 'rest_invalid_param', sprintf( __( 'Unknown value: %s', 'jetpack-send-pkg' ), $item ), array( 'status' => 400 ) ); |
| 280 | } |
| 281 | } |
| 282 | return true; |
| 283 | }, |
| 284 | 'sanitize_callback' => array( __CLASS__, 'to_list' ), |
| 285 | ); |
| 286 | } |
| 287 | |
| 288 | /** |
| 289 | * A list argument's values, without blanks or repeats. |
| 290 | * |
| 291 | * @since $$next-version$$ |
| 292 | * |
| 293 | * @param mixed $value Array, comma string or one value. |
| 294 | * @return string[] |
| 295 | */ |
| 296 | public static function to_list( $value ) { |
| 297 | $items = is_array( $value ) ? $value : explode( ',', is_scalar( $value ) ? (string) $value : '' ); |
| 298 | $items = array_map( fn( $item ) => is_scalar( $item ) ? trim( (string) $item ) : '', $items ); |
| 299 | return array_values( array_unique( array_filter( $items, 'strlen' ) ) ); |
| 300 | } |
| 301 | |
| 302 | /** |
| 303 | * GET /status. |
| 304 | * |
| 305 | * @return \WP_REST_Response |
| 306 | */ |
| 307 | public static function get_status() { |
| 308 | return rest_ensure_response( self::status_data() ); |
| 309 | } |
| 310 | |
| 311 | /** |
| 312 | * The dashboard's status payload. |
| 313 | * |
| 314 | * @return array |
| 315 | */ |
| 316 | public static function status_data() { |
| 317 | $provisioner = Provisioner_Factory::get(); |
| 318 | $account = Account::get(); |
| 319 | $blockers = self::enable_blockers( $account ); |
| 320 | |
| 321 | $settings = Settings::get(); |
| 322 | $settings['log_retention_days'] = Email_Log::retention_setting(); |
| 323 | $covers_from = Email_Log::oldest_created_at(); |
| 324 | unset( $settings['debug_log_until'] ); |
| 325 | |
| 326 | return array( |
| 327 | 'provisioner' => $provisioner ? $provisioner->get_type() : null, |
| 328 | 'account' => Account::to_public( $account ), |
| 329 | 'settings' => $settings, |
| 330 | 'can_enable' => array() === $blockers, |
| 331 | 'enable_blockers' => $blockers, |
| 332 | 'suggested_domain' => self::suggested_domain(), |
| 333 | 'site_title' => wp_specialchars_decode( (string) get_bloginfo( 'name' ), ENT_QUOTES ), |
| 334 | 'admin_email' => (string) get_option( 'admin_email' ), |
| 335 | 'current_user_email' => (string) wp_get_current_user()->user_email, |
| 336 | 'connect_url' => class_exists( '\Automattic\Jetpack\My_Jetpack\Initializer' ) ? admin_url( 'admin.php?page=my-jetpack#/connection' ) : '', |
| 337 | 'spf_suggestion' => $account ? Dns_Check::spf_suggestion( self::known_spf( $account ) ) : null, |
| 338 | 'onboarding' => Onboarding::state(), |
| 339 | 'next_check_at' => Dns_Watch::next_check_at(), |
| 340 | 'sender_avatar' => self::sender_avatar( $account ), |
| 341 | 'site_icon_url' => self::site_icon_url(), |
| 342 | 'log_retention_days' => Email_Log::retention_days(), |
| 343 | 'log_retention_locked' => Email_Log::retention_locked(), |
| 344 | 'log_covers_from' => null === $covers_from ? null : Email_Log::iso( $covers_from ), |
| 345 | 'log_trimmed_before' => self::trimmed_before( $covers_from ), |
| 346 | 'debug_log' => Debug_Log::state(), |
| 347 | 'has_history' => Stats::has_history(), |
| 348 | ); |
| 349 | } |
| 350 | |
| 351 | /** |
| 352 | * The oldest email still in the log, when older ones were deleted from it: the counts reach an |
| 353 | * earlier site-local day. A new site's counts start the day its log does, so it gets null. |
| 354 | * |
| 355 | * @param string|null $covers_from The oldest row's UTC `Y-m-d H:i:s`. |
| 356 | * @return string|null ISO 8601 UTC. |
| 357 | */ |
| 358 | private static function trimmed_before( $covers_from ) { |
| 359 | $since = Stats::since(); |
| 360 | if ( null === $covers_from || null === $since ) { |
| 361 | return null; |
| 362 | } |
| 363 | $oldest = Period::local( (int) strtotime( $covers_from . ' UTC' ) )->format( 'Y-m-d' ); |
| 364 | return Period::local( $since )->format( 'Y-m-d' ) < $oldest ? Email_Log::iso( $covers_from ) : null; |
| 365 | } |
| 366 | |
| 367 | /** |
| 368 | * The From address's avatar, keyed by that address so the page uses it only while it shows the same one. |
| 369 | * Before setup the address is the one setup suggests: the admin's mailbox on the domain, else `wordpress@`. |
| 370 | * |
| 371 | * @param array|null $account Stored account. |
| 372 | * @return array{email: string, url: string}|null Null with avatars off or no valid address. |
| 373 | */ |
| 374 | private static function sender_avatar( $account ) { |
| 375 | $settings = Settings::get(); |
| 376 | $domain = $account ? (string) $account['domain'] : self::suggested_domain(); |
| 377 | $local = (string) $settings['from_local_part']; |
| 378 | if ( ! $account && in_array( $local, array( '', 'wordpress' ), true ) ) { |
| 379 | $admin = explode( '@', strtolower( (string) get_option( 'admin_email' ) ) ); |
| 380 | if ( 2 === count( $admin ) && $admin[1] === $domain && '' !== $admin[0] ) { |
| 381 | $local = $admin[0]; |
| 382 | } |
| 383 | } |
| 384 | $email = is_email( $local . '@' . $domain ); |
| 385 | $url = is_string( $email ) ? self::avatar_for_email( $email ) : null; |
| 386 | return null === $url ? null : array( |
| 387 | 'email' => $email, |
| 388 | 'url' => $url, |
| 389 | ); |
| 390 | } |
| 391 | |
| 392 | /** |
| 393 | * The site icon, which stands in for the sender when the From address has no avatar. |
| 394 | * |
| 395 | * @return string|null Null when the site has none. |
| 396 | */ |
| 397 | private static function site_icon_url() { |
| 398 | $url = get_site_icon_url( self::AVATAR_SIZE ); |
| 399 | return is_string( $url ) && '' !== $url ? $url : null; |
| 400 | } |
| 401 | |
| 402 | /** |
| 403 | * The domain to suggest for sending: the site's host without `www.`. |
| 404 | * |
| 405 | * @return string |
| 406 | */ |
| 407 | public static function suggested_domain() { |
| 408 | $domain = preg_replace( '/^www\./', '', strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ) ); |
| 409 | |
| 410 | /** |
| 411 | * Filters the domain Send suggests for sending, e.g. for a site whose address is not the domain it sends from. |
| 412 | * |
| 413 | * @since $$next-version$$ |
| 414 | * |
| 415 | * @param string $domain Lowercased host of the site address, without `www.`. |
| 416 | */ |
| 417 | return strtolower( trim( (string) apply_filters( 'jetpack_send_suggested_domain', (string) $domain ) ) ); |
| 418 | } |
| 419 | |
| 420 | /** |
| 421 | * The domain's SPF record as last seen: by the latest DNS check, else at setup. |
| 422 | * |
| 423 | * @param array $account Stored account. |
| 424 | * @return string |
| 425 | */ |
| 426 | private static function known_spf( array $account ) { |
| 427 | $check = $account['dns_check'] ?? null; |
| 428 | if ( is_array( $check ) && isset( $check['existing_spf'] ) ) { |
| 429 | return (string) $check['existing_spf']; |
| 430 | } |
| 431 | return (string) ( $account['existing_spf'] ?? '' ); |
| 432 | } |
| 433 | |
| 434 | /** |
| 435 | * Why sending can't be turned on yet; empty when it can. |
| 436 | * |
| 437 | * @param array|null $account Stored account. |
| 438 | * @return string[] |
| 439 | */ |
| 440 | public static function enable_blockers( $account ) { |
| 441 | if ( ! $account ) { |
| 442 | return array( 'not_set_up' ); |
| 443 | } |
| 444 | $check = $account['dns_check'] ?? null; |
| 445 | if ( ! is_array( $check ) ) { |
| 446 | return array( 'dns_not_checked' ); |
| 447 | } |
| 448 | |
| 449 | // DMARC needs one aligned record: DKIM when the domain has a key, otherwise SPF, which is optional only then. |
| 450 | $has_dkim = in_array( 'dkim', array_column( (array) ( $account['dns_records'] ?? array() ), 'purpose' ), true ); |
| 451 | $required = array( 'domain_lockdown', 'sender_domain', $has_dkim ? 'dkim' : 'spf' ); |
| 452 | |
| 453 | $blockers = array(); |
| 454 | foreach ( $required as $key ) { |
| 455 | if ( 'passed' !== ( $check[ $key ] ?? '' ) ) { |
| 456 | $blockers[] = $key; |
| 457 | } |
| 458 | } |
| 459 | return $blockers; |
| 460 | } |
| 461 | |
| 462 | /** |
| 463 | * POST /setup. |
| 464 | * |
| 465 | * @param WP_REST_Request $request Request. |
| 466 | * @return \WP_REST_Response|WP_Error |
| 467 | */ |
| 468 | public static function setup( WP_REST_Request $request ) { |
| 469 | $provisioner = Provisioner_Factory::get(); |
| 470 | if ( ! $provisioner ) { |
| 471 | return new WP_Error( 'jetpack_send_no_provisioner', __( 'Connect Jetpack before setting up Send.', 'jetpack-send-pkg' ), array( 'status' => 400 ) ); |
| 472 | } |
| 473 | |
| 474 | $domain = self::sanitize_domain( (string) $request->get_param( 'domain' ) ); |
| 475 | if ( '' === $domain ) { |
| 476 | return new WP_Error( 'jetpack_send_invalid_domain', __( 'Enter a domain you own, like example.com.', 'jetpack-send-pkg' ), array( 'status' => 400 ) ); |
| 477 | } |
| 478 | |
| 479 | $result = $provisioner->provision( $domain ); |
| 480 | $account = is_wp_error( $result ) ? $result : Account::from_provision( $result, $domain, $provisioner->get_type() ); |
| 481 | if ( is_wp_error( $account ) ) { |
| 482 | return self::error_response( $account ); |
| 483 | } |
| 484 | |
| 485 | $account['existing_spf'] = Dns_Check::existing_spf( $domain ); |
| 486 | Account::save( $account ); |
| 487 | Settings::update( array( 'enabled' => false ) ); |
| 488 | delete_transient( self::CHECK_DNS_THROTTLE ); |
| 489 | Onboarding::restart( (string) wp_get_current_user()->user_email ); |
| 490 | Dns_Watch::start(); |
| 491 | return rest_ensure_response( self::status_data() ); |
| 492 | } |
| 493 | |
| 494 | /** |
| 495 | * POST /check-dns. |
| 496 | * |
| 497 | * @return \WP_REST_Response|WP_Error |
| 498 | */ |
| 499 | public static function check_dns() { |
| 500 | $account = Account::get(); |
| 501 | if ( ! $account ) { |
| 502 | return self::not_set_up(); |
| 503 | } |
| 504 | |
| 505 | // Inside the window, answer from the last check rather than asking the mail service again. |
| 506 | if ( false === get_transient( self::CHECK_DNS_THROTTLE ) ) { |
| 507 | $response = ( new MailChannels_Client( $account['api_key'] ) )->check_domain( $account['domain'], (string) $account['dkim_selector'] ); |
| 508 | if ( is_wp_error( $response ) ) { |
| 509 | return self::error_response( $response ); |
| 510 | } |
| 511 | // Throttle only after a real answer, so a failed call doesn't hide behind a stale result. |
| 512 | set_transient( self::CHECK_DNS_THROTTLE, time(), self::CHECK_DNS_WINDOW ); |
| 513 | $check = Dns_Check::normalize( $response, Dns_Check::existing_spf( $account['domain'] ) ); |
| 514 | Account::update( array( 'dns_check' => $check ) ); |
| 515 | self::note_dns_check( (string) $account['domain'], $check ); |
| 516 | } |
| 517 | |
| 518 | // The user is watching, so no automatic test: they send one themselves. |
| 519 | if ( Onboarding::maybe_auto_enable() ) { |
| 520 | Dns_Watch::stop(); |
| 521 | } |
| 522 | return rest_ensure_response( self::status_data() ); |
| 523 | } |
| 524 | |
| 525 | /** |
| 526 | * Note a domain check's verdicts in the debug log. |
| 527 | * |
| 528 | * @since $$next-version$$ |
| 529 | * |
| 530 | * @param string $domain The sending domain. |
| 531 | * @param array $check Dns_Check::normalize()'s result. |
| 532 | * @return void |
| 533 | */ |
| 534 | public static function note_dns_check( $domain, array $check ) { |
| 535 | Debug_Log::add( |
| 536 | 'info', |
| 537 | 'domain', |
| 538 | 'dns.check', |
| 539 | /* translators: 1: the sending permission record's verdict, 2: the signing record's, 3: the sender list's; e.g. passed. */ |
| 540 | sprintf( __( 'Checked the domain: permission %1$s, signing %2$s, sender list %3$s.', 'jetpack-send-pkg' ), $check['domain_lockdown'] ?? '', $check['dkim'] ?? '', $check['spf'] ?? '' ), |
| 541 | array( |
| 542 | 'domain' => $domain, |
| 543 | 'lockdown' => $check['domain_lockdown'] ?? '', |
| 544 | 'dkim' => $check['dkim'] ?? '', |
| 545 | 'spf' => $check['spf'] ?? '', |
| 546 | 'sender_domain' => $check['sender_domain'] ?? '', |
| 547 | ) |
| 548 | ); |
| 549 | } |
| 550 | |
| 551 | /** |
| 552 | * POST /settings. |
| 553 | * |
| 554 | * @param WP_REST_Request $request Request. |
| 555 | * @return \WP_REST_Response|WP_Error |
| 556 | */ |
| 557 | public static function update_settings( WP_REST_Request $request ) { |
| 558 | $changes = array(); |
| 559 | foreach ( array( 'enabled', 'fallback', 'auto_enable', 'from_name', 'from_local_part', 'reply_to', 'log_retention_days', 'debug_log_days' ) as $key ) { |
| 560 | if ( $request->has_param( $key ) ) { |
| 561 | $changes[ $key ] = $request->get_param( $key ); |
| 562 | } |
| 563 | } |
| 564 | $onboarding = $request->has_param( 'onboarding' ) ? $request->get_param( 'onboarding' ) : null; |
| 565 | $debug = $request->has_param( 'debug_log' ) ? (bool) $request->get_param( 'debug_log' ) : null; |
| 566 | |
| 567 | if ( isset( $changes['log_retention_days'] ) && Email_Log::retention_locked() ) { |
| 568 | return new WP_Error( 'jetpack_send_retention_locked', __( 'Your site’s code sets how long emails are kept.', 'jetpack-send-pkg' ), array( 'status' => 400 ) ); |
| 569 | } |
| 570 | |
| 571 | if ( ! empty( $changes['enabled'] ) ) { |
| 572 | $blockers = self::enable_blockers( Account::get() ); |
| 573 | if ( $blockers ) { |
| 574 | return new WP_Error( |
| 575 | 'jetpack_send_cannot_enable', |
| 576 | __( 'Finish setting up your domain before turning on sending.', 'jetpack-send-pkg' ), |
| 577 | array( |
| 578 | 'status' => 400, |
| 579 | 'blockers' => $blockers, |
| 580 | ) |
| 581 | ); |
| 582 | } |
| 583 | } |
| 584 | if ( isset( $changes['from_local_part'] ) && ! is_email( $changes['from_local_part'] . '@example.com' ) ) { |
| 585 | return new WP_Error( 'jetpack_send_invalid_from', __( 'Use letters, numbers, dots or dashes.', 'jetpack-send-pkg' ), array( 'status' => 400 ) ); |
| 586 | } |
| 587 | if ( isset( $changes['reply_to'] ) && '' !== trim( (string) $changes['reply_to'] ) && ! is_email( trim( (string) $changes['reply_to'] ) ) ) { |
| 588 | return new WP_Error( 'jetpack_send_invalid_reply_to', __( 'Enter an email address for replies, like you@example.com.', 'jetpack-send-pkg' ), array( 'status' => 400 ) ); |
| 589 | } |
| 590 | if ( null !== $onboarding ) { |
| 591 | $invalid = Onboarding::validate_client( $onboarding ); |
| 592 | if ( $invalid ) { |
| 593 | return $invalid; |
| 594 | } |
| 595 | } |
| 596 | |
| 597 | $kept_before = Email_Log::retention_days(); |
| 598 | $debug_days = (int) Settings::get()['debug_log_days']; |
| 599 | Settings::update( $changes ); |
| 600 | if ( ! empty( $changes['enabled'] ) ) { |
| 601 | Onboarding::note_enabled(); |
| 602 | } |
| 603 | if ( null !== $onboarding ) { |
| 604 | Onboarding::update_from_client( array_intersect_key( $onboarding, array_flip( Onboarding::CLIENT_KEYS ) ) ); |
| 605 | } |
| 606 | self::apply_debug_change( $debug, isset( $changes['debug_log_days'] ) && (int) $changes['debug_log_days'] !== $debug_days ); |
| 607 | if ( $changes || null !== $debug ) { |
| 608 | $keys = array_keys( $changes + ( null === $debug ? array() : array( 'debug_log' => $debug ) ) ); |
| 609 | // The switch sends its days with it, so one change isn't named twice. |
| 610 | if ( null !== $debug ) { |
| 611 | $keys = array_values( array_diff( $keys, array( 'debug_log_days' ) ) ); |
| 612 | } |
| 613 | /* translators: %s: the settings that changed, e.g. "backup mail, how long the log keeps emails". */ |
| 614 | Debug_Log::add( 'info', 'settings', 'settings.changed', sprintf( __( 'Changed %s.', 'jetpack-send-pkg' ), implode( ', ', array_map( array( __CLASS__, 'setting_name' ), $keys ) ) ), array( 'keys' => $keys ) ); |
| 615 | } |
| 616 | |
| 617 | $data = self::status_data(); |
| 618 | // A shorter retention deletes older emails now, and the rest in the background if there are many. |
| 619 | $kept_now = Email_Log::retention_days(); |
| 620 | if ( isset( $changes['log_retention_days'] ) && $kept_now > 0 && ( 0 === $kept_before || $kept_now < $kept_before ) ) { |
| 621 | $data['pruned'] = Email_Log::prune( 10 ); |
| 622 | if ( $data['pruned']['remaining'] > 0 ) { |
| 623 | wp_schedule_single_event( time(), Email_Log::PRUNE_HOOK ); |
| 624 | } |
| 625 | $data = array_merge( self::status_data(), array( 'pruned' => $data['pruned'] ) ); |
| 626 | } |
| 627 | return rest_ensure_response( $data ); |
| 628 | } |
| 629 | |
| 630 | /** |
| 631 | * A setting's plain name for the debug log; its key when it has none. |
| 632 | * |
| 633 | * @param string $key Setting key. |
| 634 | * @return string |
| 635 | */ |
| 636 | private static function setting_name( $key ) { |
| 637 | $names = array( |
| 638 | 'enabled' => __( 'sending', 'jetpack-send-pkg' ), |
| 639 | 'fallback' => __( 'backup mail', 'jetpack-send-pkg' ), |
| 640 | 'auto_enable' => __( 'turning sending on by itself', 'jetpack-send-pkg' ), |
| 641 | 'from_name' => __( 'the sender name', 'jetpack-send-pkg' ), |
| 642 | 'log_retention_days' => __( 'how long the log keeps emails', 'jetpack-send-pkg' ), |
| 643 | 'debug_log' => __( 'the debug log', 'jetpack-send-pkg' ), |
| 644 | 'debug_log_days' => __( 'how long the debug log records', 'jetpack-send-pkg' ), |
| 645 | ); |
| 646 | return $names[ $key ] ?? (string) $key; |
| 647 | } |
| 648 | |
| 649 | /** |
| 650 | * Turn the debug log on or off, or restart it when its days changed while it's on. |
| 651 | * |
| 652 | * @param bool|null $turn_on True or false from the request; null when it wasn't sent. |
| 653 | * @param bool $days_changed Whether `debug_log_days` changed. |
| 654 | * @return void |
| 655 | */ |
| 656 | private static function apply_debug_change( $turn_on, $days_changed ) { |
| 657 | $days = (int) Settings::get()['debug_log_days']; |
| 658 | if ( true === $turn_on && ( ! Debug_Log::enabled() || $days_changed ) ) { |
| 659 | Debug_Log::turn_on( $days ); |
| 660 | } elseif ( false === $turn_on && ! empty( Settings::get()['debug_log'] ) ) { |
| 661 | Debug_Log::turn_off(); |
| 662 | } elseif ( null === $turn_on && $days_changed && Debug_Log::enabled() ) { |
| 663 | Debug_Log::turn_on( $days ); |
| 664 | } |
| 665 | } |
| 666 | |
| 667 | /** |
| 668 | * POST /test: one test email to the current user, once the domain is ready, rate-limited per site. |
| 669 | * |
| 670 | * @param WP_REST_Request $request Request. |
| 671 | * @return \WP_REST_Response|WP_Error |
| 672 | */ |
| 673 | public static function send_test( WP_REST_Request $request ) { |
| 674 | $account = Account::get(); |
| 675 | if ( ! $account ) { |
| 676 | return self::not_set_up(); |
| 677 | } |
| 678 | |
| 679 | // Only ever the signed-in user's own address, so the route can't be used to relay mail. |
| 680 | $me = (string) wp_get_current_user()->user_email; |
| 681 | $to = $request->has_param( 'to' ) ? trim( (string) $request->get_param( 'to' ) ) : $me; |
| 682 | if ( '' === $me || ! is_email( $me ) || strtolower( $to ) !== strtolower( $me ) ) { |
| 683 | return new WP_Error( 'jetpack_send_bad_recipient', __( 'Test emails can only go to your own email address.', 'jetpack-send-pkg' ), array( 'status' => 400 ) ); |
| 684 | } |
| 685 | |
| 686 | if ( array() !== self::enable_blockers( $account ) ) { |
| 687 | return new WP_Error( |
| 688 | 'jetpack_send_not_ready', |
| 689 | /* translators: %s: the domain emails are sent from, e.g. example.com. */ |
| 690 | sprintf( __( 'Finish connecting %s first.', 'jetpack-send-pkg' ), $account['domain'] ), |
| 691 | array( 'status' => 400 ) |
| 692 | ); |
| 693 | } |
| 694 | |
| 695 | if ( ! Onboarding::claim_test() ) { |
| 696 | return new WP_Error( |
| 697 | 'jetpack_send_rate_limited', |
| 698 | __( 'You’ve sent a few tests already. Try again in a few minutes.', 'jetpack-send-pkg' ), |
| 699 | array( |
| 700 | 'status' => 429, |
| 701 | 'retry_after' => Onboarding::test_retry_after(), |
| 702 | ) |
| 703 | ); |
| 704 | } |
| 705 | |
| 706 | $result = Mailer::send_test( $me ); |
| 707 | if ( is_wp_error( $result ) ) { |
| 708 | $data = $result->get_error_data(); |
| 709 | $response = array( |
| 710 | 'success' => false, |
| 711 | 'error' => $result->get_error_message(), |
| 712 | ); |
| 713 | if ( is_array( $data ) && ! empty( $data['log_id'] ) ) { |
| 714 | $response['log_id'] = (string) $data['log_id']; |
| 715 | } |
| 716 | if ( is_array( $data ) && ! empty( $data['reason'] ) ) { |
| 717 | $response['reason'] = (string) $data['reason']; |
| 718 | } |
| 719 | return rest_ensure_response( $response ); |
| 720 | } |
| 721 | |
| 722 | $response = array( |
| 723 | 'success' => true, |
| 724 | 'request_id' => $result['request_id'], |
| 725 | ); |
| 726 | if ( '' !== $result['log_id'] ) { |
| 727 | $response['log_id'] = $result['log_id']; |
| 728 | } |
| 729 | return rest_ensure_response( $response ); |
| 730 | } |
| 731 | |
| 732 | /** |
| 733 | * GET /usage. |
| 734 | * |
| 735 | * @return \WP_REST_Response|WP_Error |
| 736 | */ |
| 737 | public static function get_usage() { |
| 738 | $account = Account::get(); |
| 739 | if ( ! $account ) { |
| 740 | return self::not_set_up(); |
| 741 | } |
| 742 | return rest_ensure_response( self::usage_data( $account ) ); |
| 743 | } |
| 744 | |
| 745 | /** |
| 746 | * This month's emails, from the dashboard's counts so deleting emails from the log doesn't lower it, |
| 747 | * plus the plan's limit when the mail service gives one. The service's own count isn't shown: it can |
| 748 | * lag behind sends that already went out. |
| 749 | * |
| 750 | * @param array $account Stored account. |
| 751 | * @param int|null $now Unix time; defaults to now. |
| 752 | * @return array{emails: int, tests: int, month_start: string, resets_on: string, monthly_limit: int, total_usage: int|null, period_start: string, period_end: string} |
| 753 | */ |
| 754 | public static function usage_data( array $account, $now = null ) { |
| 755 | $month_start = self::site_moment( $now )->format( 'Y-m-01' ); |
| 756 | $counts = Stats::sent_counts( Period::day_start( $month_start ) ); |
| 757 | $data = array( |
| 758 | 'emails' => $counts['emails'], |
| 759 | 'tests' => $counts['tests'], |
| 760 | 'month_start' => $month_start, |
| 761 | 'resets_on' => ( new \DateTimeImmutable( $month_start, new \DateTimeZone( 'UTC' ) ) )->modify( 'first day of next month' )->format( 'Y-m-d' ), |
| 762 | 'monthly_limit' => (int) ( $account['monthly_limit'] ?? 0 ), |
| 763 | 'total_usage' => null, |
| 764 | 'period_start' => '', |
| 765 | 'period_end' => '', |
| 766 | ); |
| 767 | |
| 768 | $provisioner = Provisioner_Factory::get(); |
| 769 | $usage = $provisioner && $provisioner->get_type() === $account['provider'] ? $provisioner->get_usage( $account ) : null; |
| 770 | if ( is_array( $usage ) ) { |
| 771 | $data['monthly_limit'] = (int) ( $usage['monthly_limit'] ?? $data['monthly_limit'] ); |
| 772 | $data['total_usage'] = isset( $usage['total_usage'] ) ? (int) $usage['total_usage'] : null; |
| 773 | $data['period_start'] = (string) ( $usage['period_start'] ?? '' ); |
| 774 | $data['period_end'] = (string) ( $usage['period_end'] ?? '' ); |
| 775 | } |
| 776 | return $data; |
| 777 | } |
| 778 | |
| 779 | /** |
| 780 | * GET /stats: email activity for a preset range or custom days, in the site's timezone, from the |
| 781 | * dashboard's counts. Tests are counted apart. |
| 782 | * |
| 783 | * @param WP_REST_Request $request Request. |
| 784 | * @return \WP_REST_Response|WP_Error |
| 785 | */ |
| 786 | public static function get_stats( WP_REST_Request $request ) { |
| 787 | if ( ! Account::get() ) { |
| 788 | return self::not_set_up(); |
| 789 | } |
| 790 | $custom = $request->has_param( 'start' ) || $request->has_param( 'end' ); |
| 791 | $period = Period::resolve( |
| 792 | $custom |
| 793 | ? array( |
| 794 | 'start' => (string) $request->get_param( 'start' ), |
| 795 | 'end' => (string) $request->get_param( 'end' ), |
| 796 | ) |
| 797 | : array( 'range' => (int) $request->get_param( 'range' ) ), |
| 798 | self::MAX_STATS_DAYS |
| 799 | ); |
| 800 | if ( is_wp_error( $period ) ) { |
| 801 | return $period; |
| 802 | } |
| 803 | // Counts go back KEEP_DAYS, so a custom range can't start before that. |
| 804 | if ( $custom && $period['start'] < Period::add_days( Period::local( time() )->format( 'Y-m-d' ), 1 - Stats::KEEP_DAYS ) ) { |
| 805 | /* translators: %s: how many days back the dashboard goes, e.g. 800. */ |
| 806 | return new WP_Error( 'jetpack_send_bad_period', sprintf( __( 'Pick a start date in the last %s days.', 'jetpack-send-pkg' ), number_format_i18n( Stats::KEEP_DAYS ) ), array( 'status' => 400 ) ); |
| 807 | } |
| 808 | return rest_ensure_response( self::stats_data( $period ) ); |
| 809 | } |
| 810 | |
| 811 | /** |
| 812 | * The stats payload. A preset range is cached until a new row is logged, the counts change under |
| 813 | * the same rows (a delete, the prune, a moved count, a test's result), or the site's hour changes. |
| 814 | * |
| 815 | * @param int|array $period A preset range in days, or Period::resolve()'s result. |
| 816 | * @param int|null $now Unix time to count back from; defaults to now. |
| 817 | * @return array |
| 818 | */ |
| 819 | public static function stats_data( $period, $now = null ) { |
| 820 | $now = null === $now ? time() : (int) $now; |
| 821 | if ( ! is_array( $period ) ) { |
| 822 | $period = Period::resolve( array( 'range' => in_array( (int) $period, Period::PRESETS, true ) ? (int) $period : 90 ), 0, $now ); |
| 823 | } |
| 824 | $preset = in_array( $period['range'], Period::PRESETS, true ) ? (int) $period['range'] : null; |
| 825 | $moment = Period::local( $now ); |
| 826 | $check = array( |
| 827 | 'gen' => Email_Log::stats_gen(), |
| 828 | 'max_id' => Email_Log::max_id(), |
| 829 | 'local_date' => $moment->format( 'Y-m-d' ), |
| 830 | 'local_hour' => $moment->format( 'H' ), |
| 831 | // Zones like Kolkata and Kathmandu share a date and hour but not where the hour starts. |
| 832 | 'offset' => $moment->format( 'P' ), |
| 833 | ); |
| 834 | $key = Email_Log::STATS_TRANSIENT . $preset; |
| 835 | if ( null !== $preset ) { |
| 836 | $cached = get_transient( $key ); |
| 837 | if ( is_array( $cached ) && isset( $cached['data'] ) && array_intersect_key( $cached, $check ) === $check ) { |
| 838 | return $cached['data']; |
| 839 | } |
| 840 | } |
| 841 | |
| 842 | $statuses = Stats::STATUSES; |
| 843 | $series = Stats::series( $period['start_utc'], $period['end_utc'] ); |
| 844 | $totals = array_fill_keys( $statuses, 0 ); |
| 845 | $tests = 0; |
| 846 | foreach ( Stats::totals_by_source( $period['start_utc'], $period['end_utc'] ) as $source => $counts ) { |
| 847 | foreach ( $statuses as $status ) { |
| 848 | if ( 'test' === $source ) { |
| 849 | $tests += (int) ( $counts[ $status ] ?? 0 ); |
| 850 | } else { |
| 851 | $totals[ $status ] += (int) ( $counts[ $status ] ?? 0 ); |
| 852 | } |
| 853 | } |
| 854 | } |
| 855 | |
| 856 | $days = array(); |
| 857 | $hours = array(); |
| 858 | if ( 'hour' === $period['unit'] ) { |
| 859 | for ( $hour = $period['start_utc']; $hour < $period['end_utc']; $hour += HOUR_IN_SECONDS ) { |
| 860 | $hours[] = array( 'start' => gmdate( 'Y-m-d\\TH:i:s\\Z', $hour ) ) + array_fill_keys( $statuses, 0 ); |
| 861 | } |
| 862 | // Each count is the site-local hour it was written in. After a move to or from a half-hour zone |
| 863 | // those no longer start where these hours do, so each goes in the hour it starts in. |
| 864 | foreach ( $series as $bucket => $counts ) { |
| 865 | $slot = intdiv( (int) $bucket - $period['start_utc'], HOUR_IN_SECONDS ); |
| 866 | if ( (int) $bucket < $period['start_utc'] || ! isset( $hours[ $slot ] ) ) { |
| 867 | continue; |
| 868 | } |
| 869 | foreach ( $statuses as $status ) { |
| 870 | $hours[ $slot ][ $status ] += (int) ( $counts[ $status ] ?? 0 ); |
| 871 | } |
| 872 | } |
| 873 | } else { |
| 874 | $by_day = array(); |
| 875 | foreach ( $series as $bucket => $counts ) { |
| 876 | $date = Period::local( $bucket )->format( 'Y-m-d' ); |
| 877 | foreach ( $statuses as $status ) { |
| 878 | $by_day[ $date ][ $status ] = ( $by_day[ $date ][ $status ] ?? 0 ) + $counts[ $status ]; |
| 879 | } |
| 880 | } |
| 881 | // Step through dates, not times: where the clocks go forward at midnight, a day starts at 01:00. |
| 882 | $last = Period::local( $period['end_utc'] - 1 )->format( 'Y-m-d' ); |
| 883 | for ( $date = Period::local( $period['start_utc'] )->format( 'Y-m-d' ); $date <= $last; $date = Period::add_days( $date, 1 ) ) { |
| 884 | $days[] = array( 'date' => $date ) + ( $by_day[ $date ] ?? array_fill_keys( $statuses, 0 ) ); |
| 885 | } |
| 886 | } |
| 887 | |
| 888 | $last_email = Email_Log::last_email_at(); |
| 889 | $newest = Stats::newest(); |
| 890 | $since = Stats::since(); |
| 891 | $covers = Email_Log::oldest_created_at(); |
| 892 | $data = array( |
| 893 | 'range' => $preset, |
| 894 | 'from' => $period['start'], |
| 895 | 'to' => $period['end'], |
| 896 | 'unit' => $period['unit'], |
| 897 | 'totals' => array( 'emails' => array_sum( $totals ) ) + $totals, |
| 898 | 'tests' => $tests, |
| 899 | 'days' => $days, |
| 900 | 'hours' => $hours, |
| 901 | 'origins' => self::top_origins( Stats::origins( $period['start_utc'], $period['end_utc'] ) ), |
| 902 | 'attention' => Email_Log::attention( $period['start_utc'], $period['end_utc'] ), |
| 903 | 'last_email_at' => null === $last_email ? null : Email_Log::iso( $last_email ), |
| 904 | 'last_email_day' => null === $newest ? null : Period::local( $newest )->format( 'Y-m-d' ), |
| 905 | 'has_history' => Stats::has_history(), |
| 906 | 'stats_since' => null === $since ? null : Period::local( $since )->format( 'Y-m-d' ), |
| 907 | 'log_covers_from' => null === $covers ? null : Email_Log::iso( $covers ), |
| 908 | ); |
| 909 | |
| 910 | if ( null !== $preset ) { |
| 911 | set_transient( $key, $check + array( 'data' => $data ), 10 * MINUTE_IN_SECONDS ); |
| 912 | } |
| 913 | return $data; |
| 914 | } |
| 915 | |
| 916 | /** |
| 917 | * The five busiest origins with plain names and plugin icons, plus one row for the rest. |
| 918 | * |
| 919 | * @param array $rows `origin` and `n` per origin, most first. |
| 920 | * @return array<int, array{key: string, label: string, count: int, icon_url: string|null}> |
| 921 | */ |
| 922 | private static function top_origins( array $rows ) { |
| 923 | $top = array(); |
| 924 | $rest = 0; |
| 925 | foreach ( array_values( $rows ) as $index => $row ) { |
| 926 | if ( $index < 5 ) { |
| 927 | $top[] = array( |
| 928 | 'key' => (string) $row['origin'], |
| 929 | 'label' => Email_Origin::label( (string) $row['origin'] ), |
| 930 | 'count' => (int) $row['n'], |
| 931 | 'icon_url' => Email_Origin::icon_url( (string) $row['origin'] ), |
| 932 | ); |
| 933 | } else { |
| 934 | $rest += (int) $row['n']; |
| 935 | } |
| 936 | } |
| 937 | if ( $rest > 0 ) { |
| 938 | $top[] = array( |
| 939 | 'key' => 'other', |
| 940 | 'label' => __( 'Everything else', 'jetpack-send-pkg' ), |
| 941 | 'count' => $rest, |
| 942 | 'icon_url' => null, |
| 943 | ); |
| 944 | } |
| 945 | return $top; |
| 946 | } |
| 947 | |
| 948 | /** |
| 949 | * Now, or a given Unix time, in the site's timezone. |
| 950 | * |
| 951 | * @param int|null $now Unix time; defaults to now. |
| 952 | * @return \DateTimeImmutable |
| 953 | */ |
| 954 | private static function site_moment( $now = null ) { |
| 955 | return ( new \DateTimeImmutable( '@' . ( null === $now ? time() : (int) $now ) ) )->setTimezone( wp_timezone() ); |
| 956 | } |
| 957 | |
| 958 | /** |
| 959 | * GET /log: a page of emails, filtered and searched. |
| 960 | * |
| 961 | * @param WP_REST_Request $request Request. |
| 962 | * @return \WP_REST_Response|WP_Error |
| 963 | */ |
| 964 | public static function get_log( WP_REST_Request $request ) { |
| 965 | $args = array( |
| 966 | 'page' => (int) $request->get_param( 'page' ), |
| 967 | 'per_page' => (int) $request->get_param( 'per_page' ), |
| 968 | 'search' => (string) $request->get_param( 'search' ), |
| 969 | 'status' => (array) $request->get_param( 'status' ), |
| 970 | 'origin' => (array) $request->get_param( 'origin' ), |
| 971 | 'source' => (array) $request->get_param( 'source' ), |
| 972 | ); |
| 973 | $window = self::log_window( $request ); |
| 974 | if ( is_wp_error( $window ) ) { |
| 975 | return $window; |
| 976 | } |
| 977 | if ( null !== $window ) { |
| 978 | $args['start_utc'] = $window['start_utc']; |
| 979 | $args['end_utc'] = $window['end_utc']; |
| 980 | } |
| 981 | |
| 982 | $page = Email_Log::query( $args ); |
| 983 | foreach ( $page['items'] as $index => $item ) { |
| 984 | $page['items'][ $index ] = self::log_row( $item ); |
| 985 | } |
| 986 | return rest_ensure_response( $page ); |
| 987 | } |
| 988 | |
| 989 | /** |
| 990 | * The dates GET /log filters by: custom days, a range, an older `days` link, or none. |
| 991 | * |
| 992 | * @param WP_REST_Request $request Request. |
| 993 | * @return array|WP_Error|null Period::resolve()'s result, or null for any date. |
| 994 | */ |
| 995 | private static function log_window( WP_REST_Request $request ) { |
| 996 | if ( $request->has_param( 'start' ) || $request->has_param( 'end' ) ) { |
| 997 | return Period::resolve( |
| 998 | array( |
| 999 | 'start' => (string) $request->get_param( 'start' ), |
| 1000 | 'end' => (string) $request->get_param( 'end' ), |
| 1001 | ) |
| 1002 | ); |
| 1003 | } |
| 1004 | if ( $request->has_param( 'range' ) ) { |
| 1005 | return Period::resolve( array( 'range' => (int) $request->get_param( 'range' ) ) ); |
| 1006 | } |
| 1007 | $days = (int) $request->get_param( 'days' ); |
| 1008 | return $days > 0 ? Period::resolve( array( 'days' => $days ) ) : null; |
| 1009 | } |
| 1010 | |
| 1011 | /** |
| 1012 | * A log row as GET /log returns it: ids as strings, the origin's name and icon, and the first recipient's avatar. |
| 1013 | * |
| 1014 | * @param array $item Row from Email_Log::query(). |
| 1015 | * @return array |
| 1016 | */ |
| 1017 | private static function log_row( array $item ) { |
| 1018 | $origin = (string) ( $item['origin'] ?? '' ); |
| 1019 | $item = array_merge( |
| 1020 | $item, |
| 1021 | array( |
| 1022 | 'origin' => $origin, |
| 1023 | 'origin_label' => '' === $origin ? '' : Email_Origin::label( $origin ), |
| 1024 | 'origin_icon_url' => '' === $origin ? null : Email_Origin::icon_url( $origin ), |
| 1025 | 'from_email' => (string) ( $item['from_email'] ?? '' ), |
| 1026 | 'reason' => (string) ( $item['reason'] ?? '' ), |
| 1027 | 'resend_of' => (int) ( $item['resend_of'] ?? 0 ) > 0 ? (string) $item['resend_of'] : '', |
| 1028 | 'avatar_url' => self::avatar_url( (string) ( $item['recipients'] ?? '' ) ), |
| 1029 | ) |
| 1030 | ); |
| 1031 | return $item; |
| 1032 | } |
| 1033 | |
| 1034 | /** |
| 1035 | * GET /log/origins: what sent the emails still in the log, most first, for the Source filter. |
| 1036 | * |
| 1037 | * @return \WP_REST_Response |
| 1038 | */ |
| 1039 | public static function get_log_origins() { |
| 1040 | $origins = array(); |
| 1041 | foreach ( Email_Log::origins_in_log() as $row ) { |
| 1042 | $key = (string) $row['origin']; |
| 1043 | $origins[] = array( |
| 1044 | 'key' => $key, |
| 1045 | 'label' => Email_Origin::label( $key ), |
| 1046 | 'icon_url' => Email_Origin::icon_url( $key ), |
| 1047 | 'count' => (int) $row['n'], |
| 1048 | ); |
| 1049 | } |
| 1050 | return rest_ensure_response( $origins ); |
| 1051 | } |
| 1052 | |
| 1053 | /** |
| 1054 | * GET /log/summary: how many emails the log holds, and how many each retention choice would delete. |
| 1055 | * |
| 1056 | * @return \WP_REST_Response |
| 1057 | */ |
| 1058 | public static function get_log_summary() { |
| 1059 | return rest_ensure_response( Email_Log::summary() ); |
| 1060 | } |
| 1061 | |
| 1062 | /** |
| 1063 | * The first recipient's avatar, or null when the site hides avatars or there's no address. |
| 1064 | * |
| 1065 | * @param string $recipients The logged recipients, comma-separated, maybe with names. |
| 1066 | * @return string|null |
| 1067 | */ |
| 1068 | public static function avatar_url( $recipients ) { |
| 1069 | $email = self::first_recipient( $recipients ); |
| 1070 | return '' === $email ? null : self::avatar_for_email( $email ); |
| 1071 | } |
| 1072 | |
| 1073 | /** |
| 1074 | * One address's avatar through core, so avatar plugins and privacy filters still apply. |
| 1075 | * An address without one gets a transparent image, so the page's own initial shows through without an error. |
| 1076 | * |
| 1077 | * @param string $email Email address. |
| 1078 | * @return string|null Null when the site hides avatars. |
| 1079 | */ |
| 1080 | public static function avatar_for_email( $email ) { |
| 1081 | if ( ! get_option( 'show_avatars' ) || '' === (string) $email ) { |
| 1082 | return null; |
| 1083 | } |
| 1084 | $url = get_avatar_url( |
| 1085 | (string) $email, |
| 1086 | array( |
| 1087 | 'size' => self::AVATAR_SIZE, |
| 1088 | 'default' => 'blank', |
| 1089 | ) |
| 1090 | ); |
| 1091 | return is_string( $url ) && '' !== $url ? $url : null; |
| 1092 | } |
| 1093 | |
| 1094 | /** |
| 1095 | * The first email address in a recipient list such as `"Desk, Help" <help@example.com>, sales@example.com`. |
| 1096 | * |
| 1097 | * @param string $recipients Recipients. |
| 1098 | * @return string The address, or '' when there's no valid one. |
| 1099 | */ |
| 1100 | public static function first_recipient( $recipients ) { |
| 1101 | if ( ! preg_match( '/[^\s<>,;"\'()]+@[^\s<>,;"\'()]+/', (string) $recipients, $match ) ) { |
| 1102 | return ''; |
| 1103 | } |
| 1104 | $email = is_email( $match[0] ); |
| 1105 | return is_string( $email ) ? $email : ''; |
| 1106 | } |
| 1107 | |
| 1108 | /** |
| 1109 | * GET /log/{id}: one row with the content kept for previewing, why it failed, and what can be done. |
| 1110 | * |
| 1111 | * @param WP_REST_Request $request Request. |
| 1112 | * @return \WP_REST_Response|WP_Error |
| 1113 | */ |
| 1114 | public static function get_log_entry( WP_REST_Request $request ) { |
| 1115 | $id = (int) $request->get_param( 'id' ); |
| 1116 | $entry = Email_Log::get( $id ); |
| 1117 | if ( ! $entry ) { |
| 1118 | return self::entry_not_found(); |
| 1119 | } |
| 1120 | $from = is_array( $entry['message'] ?? null ) && is_array( $entry['message']['from'] ?? null ) ? $entry['message']['from'] : null; |
| 1121 | $entry['avatar_url'] = self::avatar_url( (string) ( $entry['recipients'] ?? '' ) ); |
| 1122 | $entry['from_avatar_url'] = $from ? self::avatar_for_email( (string) ( $from['email'] ?? '' ) ) : null; |
| 1123 | $origin = (string) ( $entry['origin'] ?? '' ); |
| 1124 | $entry['origin_label'] = '' === $origin ? '' : Email_Origin::label( $origin ); |
| 1125 | $entry['origin_icon_url'] = '' === $origin ? null : Email_Origin::icon_url( $origin ); |
| 1126 | |
| 1127 | $detail = is_string( $entry['detail'] ?? null ) ? json_decode( $entry['detail'], true ) : null; |
| 1128 | $entry['reason'] = (string) ( $entry['reason'] ?? '' ); |
| 1129 | $entry['detail'] = is_array( $detail ) ? $detail : null; |
| 1130 | $entry['resend'] = Resend::eligibility( $entry ); |
| 1131 | |
| 1132 | $resends = Email_Log::resends_of( $id ); |
| 1133 | $entry['resends'] = array_map( |
| 1134 | fn( $row ) => array( |
| 1135 | 'id' => (string) $row['id'], |
| 1136 | 'status' => (string) $row['status'], |
| 1137 | 'created_at' => (string) $row['created_at'], |
| 1138 | ), |
| 1139 | $resends['items'] |
| 1140 | ); |
| 1141 | $entry['resend_count'] = $resends['total']; |
| 1142 | $original = (int) ( $entry['resend_of'] ?? 0 ); |
| 1143 | $original_row = $original > 0 ? Email_Log::brief_of( $original ) : null; |
| 1144 | $entry['resend_of'] = $original > 0 ? (string) $original : ''; |
| 1145 | $entry['resend_of_entry'] = $original > 0 ? array( |
| 1146 | 'id' => (string) $original, |
| 1147 | 'exists' => null !== $original_row, |
| 1148 | 'created_at' => $original_row['created_at'] ?? null, |
| 1149 | 'status' => $original_row['status'] ?? null, |
| 1150 | ) : null; |
| 1151 | $entry['resent_by'] = 'resend' === ( $entry['source'] ?? '' ) ? Email_Log::user_name( (int) ( $entry['user_id'] ?? 0 ) ) : ''; |
| 1152 | $entry['debug_count'] = Debug_Log::count( $id ); |
| 1153 | $entry['support_text'] = self::support_text( $entry ); |
| 1154 | // Search columns and who resent it by id stay on the server. |
| 1155 | unset( $entry['body_text'], $entry['other_addresses'], $entry['user_id'] ); |
| 1156 | return rest_ensure_response( $entry ); |
| 1157 | } |
| 1158 | |
| 1159 | /** |
| 1160 | * A plain report of one email for support: domains only, never the subject, body, full addresses or a key. |
| 1161 | * English on purpose: support reads it. |
| 1162 | * |
| 1163 | * @param array $entry The entry, with `detail` decoded. |
| 1164 | * @return string |
| 1165 | */ |
| 1166 | private static function support_text( array $entry ) { |
| 1167 | $detail = is_array( $entry['detail'] ?? null ) ? $entry['detail'] : array(); |
| 1168 | $message = is_array( $entry['message'] ?? null ) ? $entry['message'] : array(); |
| 1169 | $created = Period::local( (int) strtotime( (string) $entry['created_at'] . ' UTC' ) ); |
| 1170 | $domain = fn( $email ) => strtolower( (string) substr( (string) strrchr( (string) $email, '@' ), 1 ) ); |
| 1171 | $account = Account::get(); |
| 1172 | $from = '' !== (string) ( $message['from']['email'] ?? '' ) ? $domain( $message['from']['email'] ) : ( $account ? (string) $account['domain'] : '' ); |
| 1173 | $to = isset( $message['to'] ) && is_array( $message['to'] ) ? array_column( $message['to'], 'email' ) : array_column( Message_Builder::parse_recipients( (string) ( $entry['recipients'] ?? '' ) ), 'email' ); |
| 1174 | $words = array_merge( (array) ( $detail['service_errors'] ?? array() ), isset( $detail['result']['reason'] ) ? array( $detail['result']['reason'] ) : array() ); |
| 1175 | $host = is_array( $detail['host'] ?? null ) ? $detail['host'] : null; |
| 1176 | $records = is_array( $detail['records'] ?? null ) ? $detail['records'] : null; |
| 1177 | $or_dash = fn( $value ) => '' === (string) $value ? '—' : (string) $value; |
| 1178 | $host_out = '—'; |
| 1179 | if ( $host ) { |
| 1180 | $host_out = 'failed' === ( $host['result'] ?? '' ) ? 'failed (' . ( $host['reason'] ?? 'host_failed' ) . ')' : (string) ( $host['result'] ?? '' ); |
| 1181 | } |
| 1182 | |
| 1183 | $lines = array( |
| 1184 | 'Jetpack Send email report', |
| 1185 | sprintf( 'When: %s (%s)', $created->format( 'Y-m-d H:i:s' ), Period::zone_label( $created ) ), |
| 1186 | 'Status: ' . $entry['status'] . ( '' !== $entry['reason'] ? ' (' . $entry['reason'] . ')' : '' ), |
| 1187 | 'From domain: ' . $or_dash( $from ), |
| 1188 | 'To domain: ' . $or_dash( implode( ', ', array_unique( array_filter( array_map( $domain, $to ) ) ) ) ), |
| 1189 | 'What happened: ' . $or_dash( Failure_Reason::what_happened( (string) $entry['status'], $detail ? $detail : null ) ), |
| 1190 | 'The service said: ' . $or_dash( Debug_Log::mask_emails( implode( ' ', array_filter( array_map( 'strval', $words ) ) ) ) ), |
| 1191 | sprintf( 'Request ID: %s Message ID: %s', $or_dash( $entry['request_id'] ?? '' ), $or_dash( $detail['message_id'] ?? ( $detail['result']['message_id'] ?? '' ) ) ), |
| 1192 | 'Time taken: ' . ( isset( $detail['timing']['ms'] ) ? (int) $detail['timing']['ms'] . ' ms' : '—' ), |
| 1193 | sprintf( 'Backup mail: %s Your host\'s mail: %s', $or_dash( $detail['fallback'] ?? '' ), $host_out ), |
| 1194 | ); |
| 1195 | if ( $records ) { |
| 1196 | $lines[] = sprintf( |
| 1197 | 'Records at the time: permission %s, signing %s, sender list %s%s', |
| 1198 | $or_dash( $records['lockdown'] ?? '' ), |
| 1199 | $or_dash( $records['dkim'] ?? '' ), |
| 1200 | $or_dash( $records['spf'] ?? '' ), |
| 1201 | empty( $records['checked_at'] ) ? '' : ' (checked ' . Period::local( (int) strtotime( (string) $records['checked_at'] ) )->format( 'Y-m-d H:i' ) . ')' |
| 1202 | ); |
| 1203 | } |
| 1204 | $lines[] = sprintf( 'Versions: Send %s, WordPress %s, PHP %s', Send::PACKAGE_VERSION, get_bloginfo( 'version' ), PHP_VERSION ); |
| 1205 | return implode( "\n", $lines ); |
| 1206 | } |
| 1207 | |
| 1208 | /** |
| 1209 | * POST /log/{id}/resend: send a copy of a logged email, to the same people, the current user or others. |
| 1210 | * |
| 1211 | * @param WP_REST_Request $request Request. |
| 1212 | * @return \WP_REST_Response|WP_Error |
| 1213 | */ |
| 1214 | public static function resend_log_entry( WP_REST_Request $request ) { |
| 1215 | $id = (int) $request->get_param( 'id' ); |
| 1216 | $entry = Email_Log::get( $id ); |
| 1217 | if ( ! $entry ) { |
| 1218 | return self::entry_not_found(); |
| 1219 | } |
| 1220 | |
| 1221 | $eligibility = Resend::eligibility( $entry ); |
| 1222 | if ( ! $eligibility['allowed'] ) { |
| 1223 | return self::resend_refused( |
| 1224 | $id, |
| 1225 | new WP_Error( |
| 1226 | 'jetpack_send_not_resendable', |
| 1227 | __( 'This email can’t be sent again.', 'jetpack-send-pkg' ), |
| 1228 | array( |
| 1229 | 'status' => 403, |
| 1230 | 'reason' => $eligibility['reason'], |
| 1231 | ) |
| 1232 | ) |
| 1233 | ); |
| 1234 | } |
| 1235 | $user = wp_get_current_user(); |
| 1236 | $lists = Resend::validate( $entry, (array) $request->get_param( 'to' ), (array) $request->get_param( 'cc' ), (array) $request->get_param( 'bcc' ), (string) $user->user_email ); |
| 1237 | if ( is_wp_error( $lists ) ) { |
| 1238 | return self::resend_refused( $id, $lists ); |
| 1239 | } |
| 1240 | |
| 1241 | // One send at a time per email, so a double click can't send two copies. |
| 1242 | if ( ! Lock::acquire( 'resend_' . $id, MINUTE_IN_SECONDS ) ) { |
| 1243 | return self::resend_refused( $id, new WP_Error( 'jetpack_send_resend_in_flight', __( 'This email is already being sent.', 'jetpack-send-pkg' ), array( 'status' => 409 ) ) ); |
| 1244 | } |
| 1245 | try { |
| 1246 | if ( ! Resend::claim() ) { |
| 1247 | $minutes = max( 1, (int) ceil( Resend::retry_after() / MINUTE_IN_SECONDS ) ); |
| 1248 | $limit = Resend::limit(); |
| 1249 | return self::resend_refused( |
| 1250 | $id, |
| 1251 | new WP_Error( |
| 1252 | 'jetpack_send_resend_rate_limited', |
| 1253 | sprintf( |
| 1254 | /* translators: 1: how many emails were resent in the last hour, 2: the sentence saying how long to wait. */ |
| 1255 | __( '%1$s %2$s', 'jetpack-send-pkg' ), |
| 1256 | sprintf( |
| 1257 | /* translators: %d: how many emails can be resent an hour. */ |
| 1258 | _n( 'You’ve resent %d email in the last hour.', 'You’ve resent %d emails in the last hour.', $limit, 'jetpack-send-pkg' ), |
| 1259 | $limit |
| 1260 | ), |
| 1261 | sprintf( |
| 1262 | /* translators: %d: minutes to wait. */ |
| 1263 | _n( 'Try again in %d minute.', 'Try again in %d minutes.', $minutes, 'jetpack-send-pkg' ), |
| 1264 | $minutes |
| 1265 | ) |
| 1266 | ), |
| 1267 | array( |
| 1268 | 'status' => 429, |
| 1269 | 'retry_after' => Resend::retry_after(), |
| 1270 | ) |
| 1271 | ) |
| 1272 | ); |
| 1273 | } |
| 1274 | $result = Resend::send( $entry, $lists['to'], $lists['cc'], $lists['bcc'], (int) $user->ID ); |
| 1275 | if ( is_wp_error( $result ) ) { |
| 1276 | return self::resend_refused( $id, $result ); |
| 1277 | } |
| 1278 | return rest_ensure_response( array( 'success' => Email_Log::STATUS_SENT === $result['status'] ) + $result ); |
| 1279 | } finally { |
| 1280 | Lock::release( 'resend_' . $id ); |
| 1281 | } |
| 1282 | } |
| 1283 | |
| 1284 | /** |
| 1285 | * Note a resend that never went out, and pass its error on. |
| 1286 | * |
| 1287 | * @param int $id The original's row id. |
| 1288 | * @param WP_Error $error Why. |
| 1289 | * @return WP_Error |
| 1290 | */ |
| 1291 | private static function resend_refused( $id, WP_Error $error ) { |
| 1292 | $data = $error->get_error_data(); |
| 1293 | $reason = is_array( $data ) && ! empty( $data['reason'] ) ? (string) $data['reason'] : (string) $error->get_error_code(); |
| 1294 | Debug_Log::add( |
| 1295 | 'warning', |
| 1296 | 'sending', |
| 1297 | 'resend.refused', |
| 1298 | /* translators: %s: why, e.g. jetpack_send_resend_rate_limited. */ |
| 1299 | sprintf( __( 'Resend refused: %s.', 'jetpack-send-pkg' ), $reason ), |
| 1300 | array( |
| 1301 | 'reason' => $reason, |
| 1302 | 'code' => $error->get_error_code(), |
| 1303 | ), |
| 1304 | $id |
| 1305 | ); |
| 1306 | return $error; |
| 1307 | } |
| 1308 | |
| 1309 | /** |
| 1310 | * DELETE /log/{id}: remove one email from the log for good. |
| 1311 | * |
| 1312 | * @param WP_REST_Request $request Request. |
| 1313 | * @return \WP_REST_Response|WP_Error |
| 1314 | */ |
| 1315 | public static function delete_log_entry( WP_REST_Request $request ) { |
| 1316 | $result = Email_Log::delete( array( (int) $request->get_param( 'id' ) ) ); |
| 1317 | if ( array() === $result['deleted'] ) { |
| 1318 | return self::entry_not_found(); |
| 1319 | } |
| 1320 | return rest_ensure_response( array( 'deleted' => array_map( 'strval', $result['deleted'] ) ) ); |
| 1321 | } |
| 1322 | |
| 1323 | /** |
| 1324 | * POST /log/delete: remove up to 100 emails at once. Ids already gone come back as `missing`, so a stale selection still works. |
| 1325 | * |
| 1326 | * @param WP_REST_Request $request Request. |
| 1327 | * @return \WP_REST_Response |
| 1328 | */ |
| 1329 | public static function delete_log_entries( WP_REST_Request $request ) { |
| 1330 | $result = Email_Log::delete( (array) $request->get_param( 'ids' ) ); |
| 1331 | return rest_ensure_response( |
| 1332 | array( |
| 1333 | 'deleted' => array_map( 'strval', $result['deleted'] ), |
| 1334 | 'missing' => array_map( 'strval', $result['missing'] ), |
| 1335 | ) |
| 1336 | ); |
| 1337 | } |
| 1338 | |
| 1339 | /** |
| 1340 | * The error for a log row that isn't there. |
| 1341 | * |
| 1342 | * @return WP_Error |
| 1343 | */ |
| 1344 | private static function entry_not_found() { |
| 1345 | return new WP_Error( 'jetpack_send_log_entry_not_found', __( 'This email is no longer in the log.', 'jetpack-send-pkg' ), array( 'status' => 404 ) ); |
| 1346 | } |
| 1347 | |
| 1348 | /** |
| 1349 | * POST /reset: clears local state even if deprovisioning fails, so the site is never stuck. |
| 1350 | * |
| 1351 | * @return \WP_REST_Response |
| 1352 | */ |
| 1353 | public static function reset() { |
| 1354 | $account = Account::get(); |
| 1355 | $provisioner = Provisioner_Factory::get(); |
| 1356 | $warning = ''; |
| 1357 | if ( $account && $provisioner && $provisioner->get_type() === $account['provider'] ) { |
| 1358 | $result = $provisioner->deprovision( $account ); |
| 1359 | if ( is_wp_error( $result ) ) { |
| 1360 | $warning = $result->get_error_message(); |
| 1361 | } |
| 1362 | } |
| 1363 | |
| 1364 | Account::delete(); |
| 1365 | Settings::update( array( 'enabled' => false ) ); |
| 1366 | Dns_Watch::stop(); |
| 1367 | Onboarding::delete(); |
| 1368 | delete_transient( self::CHECK_DNS_THROTTLE ); |
| 1369 | // The log, its counts and the debug log stay: they're the site's history, not the account's. |
| 1370 | Email_Log::clear_stats_cache(); |
| 1371 | Debug_Log::add( 'warning', 'settings', 'sending.reset', __( 'Started over: the sending account was removed.', 'jetpack-send-pkg' ) ); |
| 1372 | |
| 1373 | $data = self::status_data(); |
| 1374 | $data['reset_warning'] = $warning; |
| 1375 | return rest_ensure_response( $data ); |
| 1376 | } |
| 1377 | |
| 1378 | /** |
| 1379 | * Normalize user input to a bare lowercase domain, or '' when invalid. |
| 1380 | * |
| 1381 | * @param string $domain Input. |
| 1382 | * @return string |
| 1383 | */ |
| 1384 | public static function sanitize_domain( $domain ) { |
| 1385 | $domain = strtolower( trim( $domain ) ); |
| 1386 | $domain = (string) preg_replace( '#^[a-z]+://#', '', $domain ); |
| 1387 | $domain = rtrim( explode( '/', $domain )[0], '.' ); |
| 1388 | return preg_match( '/^(?=.{4,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/', $domain ) ? $domain : ''; |
| 1389 | } |
| 1390 | |
| 1391 | /** |
| 1392 | * The "not set up" error. |
| 1393 | * |
| 1394 | * @return WP_Error |
| 1395 | */ |
| 1396 | private static function not_set_up() { |
| 1397 | return new WP_Error( 'jetpack_send_not_set_up', __( 'Set up Send first.', 'jetpack-send-pkg' ), array( 'status' => 400 ) ); |
| 1398 | } |
| 1399 | |
| 1400 | /** |
| 1401 | * Give an upstream error a valid REST status. |
| 1402 | * |
| 1403 | * @param WP_Error $error Error. |
| 1404 | * @return WP_Error |
| 1405 | */ |
| 1406 | private static function error_response( WP_Error $error ) { |
| 1407 | $data = $error->get_error_data(); |
| 1408 | $status = is_array( $data ) && isset( $data['status'] ) ? (int) $data['status'] : 0; |
| 1409 | return new WP_Error( $error->get_error_code(), $error->get_error_message(), array( 'status' => $status >= 400 ? $status : 502 ) ); |
| 1410 | } |
| 1411 | } |