PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.8.0
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.8.0
5.8.0 5.8.1 5.7.0 5.6.2 5.6.3 5.6.1 5.6.0 5.5.0 5.4.0 5.3.2 5.3.1 5.1.6 5.1.5 trunk 2.1.5 2.11 2.12 2.13 2.15 3.0.0 3.0.1 3.0.2 3.0.3 3.0.5 3.0.51 All 41 releases
double-opt-in / src / Health / SenderDomainCheck.php

SenderDomainCheck.php in Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification 5.8.0, at src/Health/SenderDomainCheck.php

279 lines 8.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Can the sender domain of the confirmation mails be trusted by inboxes?
4 *
5 * The most common "the plugin is broken" report is a confirmation mail in
6 * spam or never delivered, because the sender domain has no SPF or DMARC
7 * record, or because the sender is a free-mail address the server may not
8 * send for. This looks the records up over DNS — no external API — and says
9 * what is missing.
10 *
11 * DKIM needs a selector only the mail provider knows, so it is not checked
12 * and not claimed.
13 *
14 * @package Forge12\DoubleOptIn\Health
15 * @since 5.8.0
16 */
17
18 declare( strict_types=1 );
19
20 namespace Forge12\DoubleOptIn\Health;
21
22 use Forge12\DoubleOptIn\Frontend\SubmitNotice;
23
24 if ( ! defined( 'ABSPATH' ) ) {
25 exit;
26 }
27
28 final class SenderDomainCheck implements HealthCheckInterface {
29
30 public const CACHE_KEY = 'f12_doi_sender_domain_dns';
31
32 private const CACHE_TTL = 12 * 3600;
33
34 /** @var callable(): string[] */
35 private $senders;
36
37 /** @var callable(string): (string[]|null) */
38 private $txtLookup;
39
40 /** @var callable(): bool */
41 private $smtpActive;
42
43 /**
44 * @param callable $senders Returns the sender addresses in use.
45 * @param callable|null $txtLookup TXT records of a host, null when DNS is unavailable.
46 * @param callable|null $smtpActive Whether something routes wp_mail() elsewhere.
47 */
48 public function __construct( callable $senders, ?callable $txtLookup = null, ?callable $smtpActive = null ) {
49 $this->senders = $senders;
50 $this->txtLookup = $txtLookup ?? array( self::class, 'dnsTxt' );
51 $this->smtpActive = $smtpActive ?? array( self::class, 'smtpPluginActive' );
52 }
53
54 public function getId(): string {
55 return 'f12_doi_sender_domain';
56 }
57
58 public function getLabel(): string {
59 return __( 'Sender domain of the confirmation mail', 'double-opt-in' );
60 }
61
62 public function getPackage(): string {
63 return 'core';
64 }
65
66 public function run(): HealthCheckResult {
67 $domains = $this->senderDomains();
68 if ( $domains === array() ) {
69 return new HealthCheckResult(
70 HealthCheckResult::STATUS_GOOD,
71 __( 'No sender domain to check', 'double-opt-in' ),
72 __( 'No active form has a sender address yet.', 'double-opt-in' ),
73 'none'
74 );
75 }
76
77 $smtp = (bool) call_user_func( $this->smtpActive );
78 $freeMail = array_values( array_intersect( $domains, array_keys( SubmitNotice::providers() ) ) );
79
80 if ( $freeMail !== array() ) {
81 return new HealthCheckResult(
82 $smtp ? HealthCheckResult::STATUS_RECOMMENDED : HealthCheckResult::STATUS_CRITICAL,
83 __( 'Confirmation mails are sent from a free-mail address', 'double-opt-in' ),
84 sprintf(
85 /* translators: %s: domain(s), e.g. gmail.com */
86 __( 'The sender address uses %s. Your web server is not allowed to send mail for that domain, so Gmail, Outlook and others reject these mails or file them as spam. Use an address of your own domain as the sender in the form settings.', 'double-opt-in' ),
87 implode( ', ', $freeMail )
88 ),
89 'freemail:' . implode( ',', $freeMail ),
90 __( 'Open the forms', 'double-opt-in' ),
91 admin_url( 'admin.php?page=f12-doi-admin#/forms' )
92 );
93 }
94
95 $records = $this->records( $domains );
96 $problems = array();
97 $debug = array();
98 foreach ( $records as $domain => $found ) {
99 if ( $found === null ) {
100 $debug[] = $domain . ':unchecked';
101 continue;
102 }
103 $missing = array();
104 if ( ! $found['spf'] ) {
105 $missing[] = 'SPF';
106 }
107 if ( ! $found['dmarc'] ) {
108 $missing[] = 'DMARC';
109 }
110 $debug[] = $domain . ':' . ( $missing === array() ? 'ok' : 'no-' . strtolower( implode( '-', $missing ) ) );
111 if ( $missing !== array() ) {
112 $problems[] = sprintf(
113 /* translators: 1: domain, 2: missing record types, e.g. "SPF, DMARC" */
114 __( '%1$s has no %2$s record.', 'double-opt-in' ),
115 $domain,
116 implode( ', ', $missing )
117 );
118 }
119 }
120
121 if ( $problems === array() ) {
122 $checked = count( array_filter( $records ) ) > 0;
123 return new HealthCheckResult(
124 HealthCheckResult::STATUS_GOOD,
125 $checked
126 ? __( 'The sender domain has SPF and DMARC records', 'double-opt-in' )
127 : __( 'The sender domain could not be checked', 'double-opt-in' ),
128 $checked
129 ? __( 'Inboxes can verify that your server may send for the sender domain. DKIM is set up at your mail provider and is not checked here.', 'double-opt-in' )
130 : __( 'DNS lookups are not available on this server.', 'double-opt-in' ),
131 implode( ' ', $debug )
132 );
133 }
134
135 $description = implode( ' ', $problems ) . ' '
136 . __( 'Without these records Gmail, Outlook and others cannot tell your confirmation mails from forged ones and file them as spam or reject them. Your host or domain provider can add them in the DNS settings.', 'double-opt-in' );
137 if ( ! $smtp ) {
138 $description .= ' ' . __( 'No SMTP plugin is active, so the mails leave through the web server. An SMTP plugin that sends through your mail provider usually fixes delivery as well.', 'double-opt-in' );
139 }
140
141 return new HealthCheckResult(
142 HealthCheckResult::STATUS_RECOMMENDED,
143 __( 'The sender domain is missing records that inboxes check', 'double-opt-in' ),
144 $description,
145 implode( ' ', $debug )
146 );
147 }
148
149 /**
150 * Lower-case domains of the valid sender addresses, unique.
151 *
152 * @return string[]
153 */
154 private function senderDomains(): array {
155 $domains = array();
156 foreach ( (array) call_user_func( $this->senders ) as $sender ) {
157 $sender = str_replace( '[_site_admin_email]', (string) get_bloginfo( 'admin_email' ), (string) $sender );
158 $sender = SubmitNotice::senderAddress( $sender );
159 if ( $sender === '' ) {
160 continue;
161 }
162 $domains[] = strtolower( substr( $sender, (int) strrpos( $sender, '@' ) + 1 ) );
163 }
164
165 return array_values( array_unique( $domains ) );
166 }
167
168 /**
169 * SPF/DMARC presence per domain; null where DNS gave no answer.
170 *
171 * @param string[] $domains
172 *
173 * @return array<string, array{spf: bool, dmarc: bool}|null>
174 */
175 private function records( array $domains ): array {
176 sort( $domains );
177 $cached = get_transient( self::CACHE_KEY );
178 if ( is_array( $cached ) && ( $cached['domains'] ?? null ) === $domains && isset( $cached['records'] ) ) {
179 return $cached['records'];
180 }
181
182 $records = array();
183 foreach ( $domains as $domain ) {
184 $txt = call_user_func( $this->txtLookup, $domain );
185 if ( ! is_array( $txt ) ) {
186 $records[ $domain ] = null;
187 continue;
188 }
189 $records[ $domain ] = array(
190 'spf' => self::has( $txt, 'v=spf1' ),
191 'dmarc' => $this->hasDmarc( $domain ),
192 );
193 }
194
195 set_transient(
196 self::CACHE_KEY,
197 array(
198 'domains' => $domains,
199 'records' => $records,
200 ),
201 self::CACHE_TTL
202 );
203
204 return $records;
205 }
206
207 /**
208 * DMARC on the domain, or on its organisational domain (a sub-domain
209 * inherits the parent's policy).
210 */
211 private function hasDmarc( string $domain ): bool {
212 $hosts = array( '_dmarc.' . $domain );
213 $labels = explode( '.', $domain );
214 if ( count( $labels ) > 2 ) {
215 $hosts[] = '_dmarc.' . implode( '.', array_slice( $labels, -2 ) );
216 }
217
218 foreach ( $hosts as $host ) {
219 $txt = call_user_func( $this->txtLookup, $host );
220 if ( is_array( $txt ) && self::has( $txt, 'v=DMARC1' ) ) {
221 return true;
222 }
223 }
224
225 return false;
226 }
227
228 /**
229 * @param string[] $txt
230 */
231 private static function has( array $txt, string $prefix ): bool {
232 foreach ( $txt as $record ) {
233 if ( stripos( ltrim( (string) $record, "\" \t" ), $prefix ) === 0 ) {
234 return true;
235 }
236 }
237
238 return false;
239 }
240
241 /**
242 * TXT records of a host; an empty list when there are none, null when
243 * DNS is not available.
244 *
245 * @return string[]|null
246 */
247 public static function dnsTxt( string $host ): ?array {
248 if ( ! function_exists( 'dns_get_record' ) ) {
249 return null;
250 }
251
252 // dns_get_record() warns on a failed lookup instead of returning false only.
253 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
254 $records = @dns_get_record( $host, DNS_TXT );
255 if ( ! is_array( $records ) ) {
256 return null;
257 }
258
259 $txt = array();
260 foreach ( $records as $record ) {
261 if ( isset( $record['entries'] ) && is_array( $record['entries'] ) ) {
262 $txt[] = implode( '', $record['entries'] );
263 } elseif ( isset( $record['txt'] ) ) {
264 $txt[] = (string) $record['txt'];
265 }
266 }
267
268 return $txt;
269 }
270
271 /**
272 * Something reconfigures PHPMailer or replaces wp_mail() — an SMTP or
273 * mail-API plugin.
274 */
275 public static function smtpPluginActive(): bool {
276 return (bool) has_action( 'phpmailer_init' ) || (bool) has_filter( 'pre_wp_mail' );
277 }
278 }
279