├── .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 |
4 | AWS SES wp_mail() drop-in
5 | Use AWS SES to send your WordPress emails. Easily.
6 | |
7 |
8 |
9 | |
10 | A Human Made project. Maintained by @joehoyle.
11 | |
12 |
13 |
14 | |
15 |
16 |
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 |
--------------------------------------------------------------------------------