├── .gitignore ├── .github └── ISSUE_TEMPLATE │ ├── config.yml │ ├── 3-feature.yml │ ├── 1-bug-report.yml │ └── 2-documentation.yml ├── composer.json ├── aws-ses-wp-mail.php ├── README.md └── inc ├── class-wp-cli-command.php └── class-ses.php /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea/ 3 | *.phar -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "humanmade/aws-ses-wp-mail", 3 | "description": "WordPress plugin to send mail via SES", 4 | "homepage": "https://github.com/humanmade/aws-ses-wp-mail", 5 | "keywords": [ 6 | "wordpress" 7 | ], 8 | "license": "GPL-2.0+", 9 | "authors": [ 10 | { 11 | "name": "Human Made Limited", 12 | "email": "support@humanmade.co.uk", 13 | "homepage": "http://hmn.md/" 14 | } 15 | ], 16 | "support": { 17 | "issues": "https://github.com/humanmade/aws-ses-wp-mail/issues", 18 | "source": "https://github.com/humanmade/aws-ses-wp-mail" 19 | }, 20 | "type": "wordpress-plugin", 21 | "require": { 22 | "php": ">=7.2", 23 | "composer/installers": "^1.0 || ^2.0", 24 | "aws/aws-sdk-php": "^3.18.0" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /aws-ses-wp-mail.php: -------------------------------------------------------------------------------- 1 | send_wp_mail( $to, $subject, $message, $headers, $attachments ); 27 | 28 | if ( is_wp_error( $result ) ) { 29 | trigger_error( 30 | // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped 31 | sprintf( 'Sendmail SES Email failed: %d %s', $result->get_error_code(), $result->get_error_message() ), 32 | E_USER_WARNING 33 | ); 34 | return false; 35 | } 36 | 37 | return $result; 38 | } 39 | endif; 40 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/3-feature.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Submit a feature request 3 | title: "[Feature]: " 4 | type: feature 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Please read [this](https://engineering.hmn.md/projects/support/) before proceeding forward. 10 | 11 | Please note that we don't offer detailed end-user support. 12 | 13 | This template is just for feature requests, not for questions. 14 | 15 | If the opened issue doesn't have all of the needed information, or it's off-topic, we will close it. 16 | 17 | Please provide as much details as possible about the feature you need. 18 | 19 | Thank you for helping us build a better product. 20 | 21 | - type: textarea 22 | id: current_behaviour 23 | attributes: 24 | label: Current Behaviour and Context 25 | description: Describe the scenario that led to this request. 26 | placeholder: How is it working now? 27 | validations: 28 | required: true 29 | 30 | - type: textarea 31 | id: improved_behaviour 32 | attributes: 33 | label: Improved Behaviour 34 | description: Description of how things should work. 35 | placeholder: How it should be working? 36 | validations: 37 | required: true 38 | 39 | - type: textarea 40 | id: additional_info 41 | attributes: 42 | label: Additional Info 43 | description: Any additional info, including ideas or examples of how this could be implemented. 44 | placeholder: Share your thoughts, examples, or references. 45 | validations: 46 | required: false 47 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/1-bug-report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Report a bug 3 | title: "[Bug]: " 4 | type: bug 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Please read [this](https://engineering.hmn.md/projects/support/) before proceeding forward. 10 | 11 | Please note that we don't offer detailed end-user support. 12 | 13 | This template is just for bug reports, not for questions. 14 | 15 | If the opened issue doesn't have all of the needed information, or it's off-topic, we will close it. 16 | 17 | Please provide as much details as possible - some bugs are hard to investigate and reproduce. 18 | 19 | Thank you for helping us fix our product. 20 | 21 | - type: textarea 22 | id: bug-description 23 | attributes: 24 | label: Bug Description 25 | description: A short description of what the bug is 26 | placeholder: Describe the bug. 27 | validations: 28 | required: true 29 | 30 | - type: textarea 31 | id: steps_to_reproduce 32 | attributes: 33 | label: Steps to Reproduce and Code Sample 34 | description: Precise steps to reproduce the behaviour. You can also add minimal code sample that reproduces the issue. 35 | placeholder: | 36 | Step 1 37 | Step 2 38 | Step 3... 39 | validations: 40 | required: true 41 | 42 | - type: textarea 43 | id: expected_behaviour 44 | attributes: 45 | label: Expected Behaviour 46 | description: Description of what you expected to happen. 47 | placeholder: What should have happened? 48 | validations: 49 | required: true 50 | 51 | - type: textarea 52 | id: additional_info 53 | attributes: 54 | label: Additional Info 55 | description: Add any other info about the problem here (screenshots, videos, related issues, etc.) 56 | placeholder: Additional information that might help. Error messages, logs, screenshots, your device, OS version, browser version, etc. 57 | validations: 58 | required: false 59 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/2-documentation.yml: -------------------------------------------------------------------------------- 1 | name: Documentation Issue 2 | description: Report an issue with documentation 3 | title: "[Docs]: " 4 | type: task 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Please read [this](https://engineering.hmn.md/projects/support/) before proceeding forward. 10 | 11 | Please note that we don't offer detailed end-user support. 12 | 13 | This template is just for documentation issues, not for questions. 14 | 15 | If the opened issue doesn't have all of the needed information, or it's off-topic, we will close it. 16 | 17 | Please provide as much details as possible about the documentation issue you've found. 18 | 19 | Thank you for helping us improve our documentation. 20 | 21 | - type: dropdown 22 | id: issue-type 23 | attributes: 24 | label: Issue Type 25 | description: What kind of documentation issue is this? 26 | options: 27 | - "Missing documentation" 28 | - "Incorrect information" 29 | - "Outdated information" 30 | - "Unclear/confusing explanation" 31 | - "Broken example/code" 32 | - "Typo/grammar error" 33 | - "Missing example" 34 | - "Broken link" 35 | - "Other" 36 | validations: 37 | required: true 38 | 39 | - type: input 40 | id: location 41 | attributes: 42 | label: Documentation Location 43 | description: Where is this documentation located? 44 | placeholder: "Document URL goes here" 45 | validations: 46 | required: true 47 | 48 | - type: textarea 49 | id: description 50 | attributes: 51 | label: Issue Description 52 | description: Describe the documentation issue in detail. 53 | placeholder: "Documentation issue explanation goes here" 54 | validations: 55 | required: true 56 | 57 | - type: textarea 58 | id: current-content 59 | attributes: 60 | label: Current Content 61 | description: If you believe it would help, please copy the current documentation content that needs to be fixed. 62 | render: markdown 63 | placeholder: "Current documentation content." 64 | 65 | - type: textarea 66 | id: suggested-content 67 | attributes: 68 | label: Suggested Improvement 69 | description: How do you think this should be documented instead? 70 | render: markdown 71 | placeholder: "Suggested documentation content." 72 | 73 | - type: textarea 74 | id: additional_info 75 | attributes: 76 | label: Additional Info 77 | description: Add any other info that would help us improve the documentation. 78 | placeholder: | 79 | - This confused me because... 80 | - Screenshots would have been helpful because... 81 | - Detailed output would have been valuable because... 82 | 83 | 84 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 9 | 12 | 15 | 16 |
4 | AWS SES wp_mail() drop-in
5 | Use AWS SES to send your WordPress emails. Easily. 6 |
10 | A Human Made project. Maintained by @joehoyle. 11 | 13 | 14 |
17 | 18 | AWS SES is a very simple UI-less plugin for sending `wp_mail()`s email via AWS SES. 19 | 20 | Getting Set Up 21 | ========== 22 | 23 | Once you have `git clone`d the repo, or added it as a Git Submodule, add the following constants to your `wp-config.php`: 24 | 25 | ```PHP 26 | define( 'AWS_SES_WP_MAIL_REGION', 'us-east-1' ); 27 | define( 'AWS_SES_WP_MAIL_KEY', '' ); 28 | define( 'AWS_SES_WP_MAIL_SECRET', '' ); 29 | define( 'AWS_SES_WP_MAIL_CONFIG_SET', '' ); 30 | ``` 31 | 32 | If you plan to use IAM instance profiles to protect your AWS credentials on disk you'll need the following configuration instead: 33 | 34 | ```PHP 35 | define('AWS_SES_WP_MAIL_REGION', 'us-east-1'); 36 | define('AWS_SES_WP_MAIL_USE_INSTANCE_PROFILE', true); 37 | ``` 38 | 39 | 40 | The next thing that you should do is to verify your sending domain for SES. You can do this via the AWS Console, which will allow you to automatically set headers if your DNS is hosted on Route 53. Alternatively, you can get the required DNS records by running: 41 | 42 | ``` 43 | wp aws-ses verify-sending-domain 44 | ``` 45 | 46 | Once you have verified your sending domain, you are all good to go! 47 | 48 | **Note:** If you have not used SES in production previously, you need to apply to [move out of the Amazon SES sandbox](http://docs.aws.amazon.com/ses/latest/DeveloperGuide/request-production-access.html). 49 | 50 | ### Configuration Sets 51 | 52 | To better track your mail activity for monitoring or statistics you can use the configuration sets. To enable it you will first need to create your Configuration Set on AWS SES Console and add the configuration set name as the value to the `AWS_SES_WP_MAIL_CONFIG_SET` constant. 53 | 54 | Detailed information on the setup and usage you find here: https://docs.aws.amazon.com/ses/latest/DeveloperGuide/using-configuration-sets.html 55 | 56 | Other Commands 57 | ======= 58 | 59 | `wp aws-ses send [--from-email=]` 60 | 61 | Send a test email via the command line. Good for testing! 62 | 63 | Credits 64 | ======= 65 | Created by Human Made for high volume and large-scale sites. We run AWS SES wp_mail() on sites with millions of monthly page views, and thousands of sites. 66 | 67 | Written and maintained by [Joe Hoyle](https://github.com/joehoyle). Thanks to all our [contributors](https://github.com/humanmade/S3-Uploads/graphs/contributors). 68 | 69 | Interested in joining in on the fun? [Join us, and become human!](https://hmn.md/is/hiring/) 70 | -------------------------------------------------------------------------------- /inc/class-wp-cli-command.php: -------------------------------------------------------------------------------- 1 | 16 | * : Email address to send to. 17 | * 18 | * 19 | * : Email subject. 20 | * 21 | * 22 | * : Email message. 23 | * 24 | * [--from-email=] 25 | * : Email address to send from. 26 | * 27 | * [--reply-to=] 28 | * : Email address to set as Reply-To. 29 | * 30 | * [--cc=] 31 | * : Email addresses to CC (comma-separated). 32 | * 33 | * [--bcc=] 34 | * : Email addresses to BCC (comma-separated). 35 | */ 36 | public function send( $args, $args_assoc ) { 37 | 38 | if ( ! empty( $args_assoc['from-email'] ) ) { 39 | add_filter( 40 | 'wp_mail_from', function() use ( $args_assoc ) { 41 | return $args_assoc['from-email']; 42 | } 43 | ); 44 | } 45 | 46 | $headers = []; 47 | if ( ! empty( $args_assoc['reply-to'] ) ) { 48 | $headers['Reply-To'] = $args_assoc['reply-to']; 49 | } 50 | if ( ! empty( $args_assoc['cc'] ) ) { 51 | $headers['CC'] = $args_assoc['cc']; 52 | } 53 | if ( ! empty( $args_assoc['bcc'] ) ) { 54 | $headers['BCC'] = $args_assoc['bcc']; 55 | } 56 | 57 | $result = SES::get_instance()->send_wp_mail( $args[0], $args[1], $args[2], $headers ); 58 | 59 | if ( is_wp_error( $result ) ) { 60 | WP_CLI::error( $result->get_error_code() . ': ' . $result->get_error_message() ); 61 | } 62 | 63 | WP_CLI::success( 'Sent.' ); 64 | } 65 | 66 | /** 67 | * Verify a domain in SES to send mail from. 68 | * 69 | * @subcommand verify-sending-domain 70 | * @synopsis [--domain=] 71 | */ 72 | public function verify_sending_domain( $args, $args_assoc ) { 73 | 74 | // Get the site domain and get rid of www. 75 | $domain = strtolower( wp_parse_url( site_url(), PHP_URL_HOST ) ); 76 | if ( 'www.' === substr( $domain, 0, 4 ) ) { 77 | $domain = substr( $domain, 4 ); 78 | } 79 | 80 | if ( isset( $args_assoc['domain'] ) ) { 81 | $domain = $args_assoc['domain']; 82 | } 83 | 84 | $dns_records = $this->get_sending_domain_dns_records( $domain ); 85 | 86 | WP_CLI::line( 'Submitted for verification. Make sure you have the following DNS records added to the domain:' ); 87 | 88 | \WP_CLI\Utils\format_items( 'table', $dns_records, [ 'Domain', 'Type', 'Value' ] ); 89 | } 90 | 91 | protected function get_sending_domain_dns_records( $domain ) { 92 | 93 | $ses = SES::get_instance()->get_client(); 94 | if ( is_wp_error( $ses ) ) { 95 | WP_CLI::error( $ses->get_error_code() . ': ' . $ses->get_error_message() ); 96 | } 97 | 98 | try { 99 | $verify = $ses->verifyDomainIdentity( 100 | [ 101 | 'Domain' => $domain, 102 | ] 103 | ); 104 | } catch ( \Exception $e ) { 105 | WP_CLI::error( get_class( $e ) . ': ' . $e->getMessage() ); 106 | } 107 | 108 | $dkim = $ses->verifyDomainDkim( 109 | [ 110 | 'Domain' => $domain, 111 | ] 112 | ); 113 | 114 | $dns_records[] = [ 115 | 'Domain' => '_amazonses.' . $domain, 116 | 'Type' => 'TXT', 117 | 'Value' => $verify['VerificationToken'], 118 | ]; 119 | 120 | foreach ( $dkim['DkimTokens'] as $token ) { 121 | $dns_records[] = [ 122 | 'Domain' => $token . '._domainkey.' . $domain, 123 | 'Type' => 'CNAME', 124 | 'Value' => $token . '.dkim.amazonses.com', 125 | ]; 126 | } 127 | 128 | return $dns_records; 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /inc/class-ses.php: -------------------------------------------------------------------------------- 1 | key = $key; 38 | $this->secret = $secret; 39 | $this->region = $region; 40 | $this->config_set = $config_set; 41 | } 42 | 43 | /** 44 | * Override WordPress' default wp_mail function with one that sends email 45 | * using the AWS SDK. 46 | * 47 | * @todo support cc, bcc 48 | * @todo support attachments 49 | * @since 0.0.1 50 | * @access public 51 | * @todo Add support for attachments 52 | * @param string $to 53 | * @param string $subject 54 | * @param string $message 55 | * @param mixed $headers 56 | * @param array $attachments 57 | * @return bool true if mail has been sent, false if it failed 58 | */ 59 | public function send_wp_mail( $to, $subject, $message, $headers = [], $attachments = [] ) { 60 | 61 | // Compact the input and apply the filters 62 | $atts = apply_filters( 'wp_mail', compact( 'to', 'subject', 'message', 'headers', 'attachments' ) ); 63 | 64 | $pre_wp_mail = apply_filters( 'pre_wp_mail', null, $atts ); 65 | 66 | if ( null !== $pre_wp_mail ) { 67 | return $pre_wp_mail; 68 | } 69 | 70 | // Extract the input 71 | list( 72 | 'to' => $to, 73 | 'subject' => $subject, 74 | 'message' => $message, 75 | 'headers' => $headers, 76 | 'attachments' => $attachments, 77 | ) = $atts; 78 | 79 | // Get headers as array 80 | if ( empty( $headers ) ) { 81 | $headers = []; 82 | } 83 | 84 | if ( ! is_array( $headers ) ) { 85 | // Explode the headers out, so this function can take both 86 | // string headers and an array of headers. 87 | $headers = array_filter( explode( "\n", str_replace( "\r\n", "\n", $headers ) ) ); 88 | } 89 | 90 | // transform headers array into a key => value map 91 | foreach ( $headers as $header => $value ) { 92 | if ( strpos( $value, ':' ) ) { 93 | $value = array_map( 'trim', explode( ':', $value, 2 ) ); 94 | $headers[ $value[0] ] = $value[1]; 95 | 96 | // Gravity Forms uses an array like 97 | // ['Content-Type' => 'Content-Type: text/html'] 98 | // so we need to ensure we don't accidentally unset the 99 | // new header. 100 | if ( $header !== $value[0] ) { 101 | unset( $headers[ $header ] ); 102 | } 103 | } 104 | } 105 | 106 | // normalize header names to Camel-Case 107 | foreach ( $headers as $name => $value ) { 108 | $uc_name = ucwords( strtolower( $name ), '-' ); 109 | if ( $uc_name !== $name ) { 110 | $headers[ $uc_name ] = $value; 111 | unset( $headers[ $name ] ); 112 | } 113 | } 114 | 115 | // Get the site domain and get rid of www. 116 | $sitename = strtolower( wp_parse_url( site_url(), PHP_URL_HOST ) ); 117 | if ( 'www.' === substr( $sitename, 0, 4 ) ) { 118 | $sitename = substr( $sitename, 4 ); 119 | } 120 | 121 | /** 122 | * Filters the address email is sent from. 123 | * 124 | * @param string $from_email The email address to send from. 125 | */ 126 | $from_email = apply_filters( 'wp_mail_from', 'no-reply@' . $sitename ); 127 | 128 | /** 129 | * Filters the name for the email sender. 130 | * 131 | * @param string $from_name The name to send email from. 132 | */ 133 | $from_name = apply_filters( 'wp_mail_from_name', get_bloginfo( 'name' ) ); 134 | 135 | $message_args = [ 136 | // Email 137 | 'subject' => $subject, 138 | 'to' => $to, 139 | 'headers' => [ 140 | 'Content-Type' => apply_filters( 'wp_mail_content_type', 'text/plain' ), 141 | 'From' => sprintf( '"%s" <%s>', mb_encode_mimeheader( $from_name ), $from_email ), 142 | ], 143 | ]; 144 | $message_args['headers'] = array_merge( $message_args['headers'], $headers ); 145 | $message_args = apply_filters( 'aws_ses_wp_mail_pre_message_args', $message_args ); 146 | 147 | // Make sure our to value is an array so we can manipulate it for the API. 148 | if ( ! is_array( $message_args['to'] ) ) { 149 | $message_args['to'] = explode( ',', $message_args['to'] ); 150 | } 151 | 152 | if ( strpos( $message_args['headers']['Content-Type'], 'text/plain' ) !== false ) { 153 | $message_args['text'] = $message; 154 | } else { 155 | $message_args['html'] = $message; 156 | } 157 | 158 | // Allow user to override message args before they're sent to Mandrill. 159 | $message_args = apply_filters( 'aws_ses_wp_mail_message_args', $message_args ); 160 | 161 | $ses = $this->get_client(); 162 | 163 | if ( is_wp_error( $ses ) ) { 164 | return $ses; 165 | } 166 | 167 | try { 168 | $args = [ 169 | 'Source' => $message_args['headers']['From'], 170 | 'Destination' => [ 171 | 'ToAddresses' => $message_args['to'], 172 | ], 173 | 'Message' => [ 174 | 'Subject' => [ 175 | 'Data' => $message_args['subject'], 176 | 'Charset' => get_bloginfo( 'charset' ), 177 | ], 178 | 'Body' => [], 179 | ], 180 | ]; 181 | 182 | if ( ! empty( $this->config_set ) ) { 183 | $args['ConfigurationSetName'] = $this->config_set; 184 | } 185 | 186 | if ( isset( $message_args['text'] ) ) { 187 | $args['Message']['Body']['Text'] = [ 188 | 'Data' => $message_args['text'], 189 | 'Charset' => get_bloginfo( 'charset' ), 190 | ]; 191 | } 192 | 193 | if ( isset( $message_args['html'] ) ) { 194 | $args['Message']['Body']['Html'] = [ 195 | 'Data' => $message_args['html'], 196 | 'Charset' => get_bloginfo( 'charset' ), 197 | ]; 198 | } 199 | 200 | if ( ! empty( $message_args['headers']['Reply-To'] ) ) { 201 | $replyto = explode( ',', $message_args['headers']['Reply-To'] ); 202 | $args['ReplyToAddresses'] = array_map( 'trim', $replyto ); 203 | } 204 | 205 | foreach ( [ 'Cc', 'Bcc' ] as $type ) { 206 | if ( empty( $message_args['headers'][ $type ] ) ) { 207 | continue; 208 | } 209 | 210 | $addrs = explode( ',', $message_args['headers'][ $type ] ); 211 | $args['Destination'][ $type . 'Addresses' ] = array_map( 'trim', $addrs ); 212 | } 213 | 214 | $args = apply_filters( 'aws_ses_wp_mail_ses_send_message_args', $args, $message_args ); 215 | $result = $ses->sendEmail( $args ); 216 | } catch ( Exception $e ) { 217 | $error = new WP_Error( 'wp_mail_failed', $e->getMessage() ); 218 | 219 | do_action( 'wp_mail_failed', $error, $message_args ); 220 | 221 | do_action( 'aws_ses_wp_mail_ses_error_sending_message', $e, $args, $message_args ); 222 | return new WP_Error( get_class( $e ), $e->getMessage() ); 223 | } 224 | 225 | do_action( 'wp_mail_succeeded', $message_args ); 226 | 227 | do_action( 'aws_ses_wp_mail_ses_sent_message', $result, $args, $message_args ); 228 | return true; 229 | } 230 | 231 | /** 232 | * Get the client for AWS SES. 233 | * 234 | * @return SesClient|WP_Error 235 | */ 236 | public function get_client() { 237 | if ( ! empty( $this->client ) ) { 238 | return $this->client; 239 | } 240 | 241 | $params = [ 242 | 'version' => 'latest', 243 | ]; 244 | 245 | if ( $this->key && $this->secret ) { 246 | $params['credentials'] = [ 247 | 'key' => $this->key, 248 | 'secret' => $this->secret, 249 | ]; 250 | } 251 | 252 | if ( $this->region ) { 253 | $params['signature'] = 'v4'; 254 | $params['region'] = $this->region; 255 | } 256 | 257 | if ( defined( 'WP_PROXY_HOST' ) && defined( 'WP_PROXY_PORT' ) ) { 258 | $proxy_auth = ''; 259 | $proxy_address = WP_PROXY_HOST . ':' . WP_PROXY_PORT; 260 | 261 | if ( defined( 'WP_PROXY_USERNAME' ) && defined( 'WP_PROXY_PASSWORD' ) ) { 262 | $proxy_auth = WP_PROXY_USERNAME . ':' . WP_PROXY_PASSWORD . '@'; 263 | } 264 | 265 | $params['request.options']['proxy'] = $proxy_auth . $proxy_address; 266 | } 267 | 268 | $params = apply_filters( 'aws_ses_wp_mail_ses_client_params', $params ); 269 | 270 | try { 271 | $this->client = SesClient::factory( $params ); 272 | } catch ( Exception $e ) { 273 | return new WP_Error( get_class( $e ), $e->getMessage() ); 274 | } 275 | 276 | return $this->client; 277 | } 278 | } 279 | --------------------------------------------------------------------------------