If the mail spooler fails to send a mail, then that mail is left in the queue for retry.

For the Drupal default mail PHP transport, this behaviour is mostly OK because a failure generally means the entire mail transport isn't working. However the cron run will continue to send potentially 1000s of emails which is inefficient and fills the log with repeat errors.

For a custom mail transport such as SwiftMailer sending via SMTP this behaviour is terrible, because a failure can indicate that the recipient is an unknown mailbox. In that case, the retry will never succeed and constant retrying is likely to cause the server to be blacklisted.

Proposed resolution

We need to distinguish a global failure from an error that is specific to a single recipient.

By default simplenews will use the following rules:

  1. If at least one mail in the list mails succeeds, any errors are converted specific failures
  2. If there are too many pending errors, then the spool run is aborted (AbortSendingException).

Create a new hook_simplenews_mail_result_alter that is called after sending each mail in a batch. The hook implementation can classify errors as follows.

  1. Global error: throw AbortSendingException to abort the current cron run, retry on next cron.
  2. Specific error: set status to STATUS_FAILED means to fail the current subscriber (without retry) and continue. The hook could also track failures and eventually block the subscriber.

Remaining tasks

Write a test.

API changes

Simplify the spool status. Currently there is an integer status and boolean error. This is redundant, awkward to code and confusing. Remove the boolean.

  • New constant SpoolStorageInterface::STATUS_FAILED.
  • Return value of MailerInterface::sendMail() is int not array (original comment was wrong).
  • Return value of SpoolListInterface::getProcessed() (original comment was wrong).
  • Second parameter of SpoolStorageInterface::updateMails() is int not array (original comment was slightly wrong).

Alter SpoolListInterface to properly batch up results. This allows batching up pending errors to decide the final verdict later. (It's also easier to understand and would allow a future performance improvement to batch up calls to updateMails).

  • New function SpoolListInterface::setResult()
  • Alter SpoolListInterface::getProcessed() to return the entire array, and no longer keyed by msid (because that forces an unnecessary duplicate array).

Data model changes

Remove simplenews_mail_spool->error.

Other minor fixes

  1. Fix various inaccurate/confusing comments and variable names
  2. Fix description on the admin page /admin/config/services/simplenews/settings/mail which is too specific to the default PHPMailManager->mail().
  3. If the entity or subscriber fails to load this should not be marked as an error as it is a perfectly normal window condition: a delete occurs after spooling and before the actual send.
  4. Create SpoolStorage::createSpoolList to make it easy to use a custom SpoolList class.

Older options

Minimum function:

  1. Prevent retries: create a boolean config setting "retry emails". If FALSE, a failed email is moved to new state SpoolStorageInterface::STATUS_FAILED.
  2. Clean up: create an integer config setting "max spool age". Emails in the spool are discarded after this number of days provided that they have had at least one attempt at sending.

Allow retry: per-newsletter or globally? Global is easiet could be on /admin/config/services/simplenews/settings/mail or /admin/people/simplenews? Show a count of failed mails with two buttons: retry and clear.

Per newsletter control:

  1. Add UI (NodeTabForm and SendStatus) to show the count of failed mails per newsletter.
  2. Once an issue has completed sending except for errors, move it to new state SIMPLENEWS_STATUS_SEND_ERRORS.
  3. In this state, behaviour of actions is SendIssue => retry; StopIssue => clear.
  4. Add UI for this state on to NodeTabForm with two buttons retry/clear.
CommentFileSizeAuthor
#54 simplenews.fail-retry.3052714-interdiff-53-54.txt6.7 KBadamps
#54 simplenews.fail-retry.3052714-54.patch49.2 KBadamps
#53 simplenews.fail-retry.3052714-interdiff-52-53.txt1.98 KBadamps
#53 simplenews.fail-retry.3052714-53.patch45.37 KBadamps
#52 simplenews.fail-retry.3052714-interdiff-51-52.txt1.79 KBadamps
#52 simplenews.fail-retry.3052714-52.patch44.39 KBadamps
#51 simplenews.fail-retry.3052714-interdiff-49-51.txt4.12 KBadamps
#51 simplenews.fail-retry.3052714-51.patch44.39 KBadamps
#49 simplenews.fail-retry.3052714-interdiff-48-49.txt14.83 KBadamps
#49 simplenews.fail-retry.3052714-49.patch41.53 KBadamps
#48 simplenews.fail-retry.3052714-interdiff-47-48.txt1.19 KBadamps
#48 simplenews.fail-retry.3052714-48.patch30.29 KBadamps
#47 simplenews.fail-retry.3052714-interdiff-44-47.txt5.36 KBadamps
#47 simplenews.fail-retry.3052714-47.patch30.27 KBadamps
#44 simplenews.fail-retry.3052714-interdiff-42-44.txt1.61 KBadamps
#44 simplenews.fail-retry.3052714-44.patch28.58 KBadamps
#42 simplenews.fail-retry.3052714-interdiff-40-42.txt907 bytesadamps
#42 simplenews.fail-retry.3052714-42.patch27.38 KBadamps
#40 simplenews.fail-retry.3052714-interdiff-39-40.txt3.73 KBadamps
#40 simplenews.fail-retry.3052714-40.patch26.44 KBadamps
#39 simplenews.fail-retry.3052714-39.patch23.11 KBadamps
#33 simplenews.fail-retry.3052714-interdiff-32-33.txt1.6 KBadamps
#33 simplenews.fail-retry.3052714-33.patch18.83 KBadamps
#32 simplenews.fail-retry.3052714-interdiff-31-32.txt10.58 KBadamps
#32 simplenews.fail-retry.3052714-32.patch17.77 KBadamps
#31 simplenews.fail-retry.3052714-interdiff-30-31.txt440 bytesadamps
#31 simplenews.fail-retry.3052714-31.patch12.39 KBadamps
#30 simplenews.fail-retry.3052714-interdiff-28-30.txt289 bytesadamps
#30 simplenews.fail-retry.3052714-30.patch12.39 KBadamps
#28 simplenews.fail-retry.3052714-interdiff-27-28.txt4.08 KBadamps
#28 simplenews.fail-retry.3052714-28.patch12.38 KBadamps
#27 simplenews.fail-retry.3052714-27.patch10.37 KBadamps

Comments

AdamPS created an issue. See original summary.

jonathanshaw’s picture

For a custom mail transport such as SwiftMailer sending via SMTP this behaviour is terrible

Agreed. I will get this fixed. Do you have a preferred solution. The easiest one is to disable retries altogether. They're complex to do right, disastrous to do wrong, and add little value in my eyes.

Possibly there could be a manual "retry" option on the node, that allows unsent only to be retried. this might relate to #3052713: Confusing send status after cancelling a part-sent newsletter . This could also be a follow-up: we disable automatic retries here, leave a manual alternative to another issue.

adamps’s picture

I think the main scenario to retry is if your entire transport has failed or is misconfigred, so there wasn't even an attempt to send the mail.

It's key here to clarify our understanding of the interface definitions MailManagerInterface and MailInterface. Both specify that the result indicates "the mail was successfully accepted for delivery".

  1. If we interpret this literally, it means that bounced email addresses should actually return success, because they were accepted for delivery. In this view, success means don't bother to retry I am handling it (e.g. SMTP might be retrying).
  2. If we follow a more "natural" approach we could return fail for any error. The interface description could be viewed as accidentally too specific to the default PHP mail implementation and it's OK for other implementations to differ.

In a way Drupal is mostly consistent with option 1. MailManager logs a fail with an error. Simplenews retries because it wasn't yet accepted for delivery. It is SwiftMailer module that fails to follow option 1 because it returns FALSE even though the message.

Concrete things we could do

  1. Try to fix SwiftMailer module! It's tricky because the library doesn't distinguish different types of failure. You could test the transport with transport->ping, and if the transport is up but the message fails it suggests a specific fault in the message so not worth retrying, hence return success.
  2. Even if we follow option 1, it still seems like a bad idea to retry forever. We could have a max retry limit, configurable in simplenews settings (and possibly also retry interval for sites with frequent hook_cron?). We could reuse the spool result which is currently described as boolean but it should hold up to 128 at least. This allows a policy of no retries by assigning 0.
  3. Next question is what to do once any allowed retries are used up. Options I can see are discard, or keep in the spool, which implies a new SpoolStorageInterface::STATUS_XXX. If keeping presumably there ought to be a time limit.
  4. The main reason for keeping is as you say to allow manual operations for retry. But as a site admin I really don't want to be doing manual operations. So maybe we shouldn't bother to keep them?
  5. We could try to detect if there is a global failure and if so a) stop sending more b) grant those already failed an exemption from discarding.

OK that's a bit of a brainstorming, but I think I'll stop writing now and let you respond indicating which ideas are attractive to you.

jonathanshaw’s picture

Try to fix SwiftMailer module!

A worthy goal, but I think we should focus here on what to do from simplenews perspective assuming it's never improved upstream.

Next question is what to do once any allowed retries are used up. Options I can see are discard, or keep in the spool ... If keeping presumably there ought to be a time limit.
The main reason for keeping is as you say to allow manual operations for retry... maybe we shouldn't bother to keep them

I think it's worth filing a follow-up for it, in case someone on day cares about it, but it's more of a new feature than a necessary part of fixing this major bug.

We could try to detect if there is a global failure and if so a) stop sending more b) grant those already failed an exemption from discarding

That's interesting. I'd still be comfortable with simply ditching retries even without it, so I could argue for it being a follow-up too, but I do kind of like it. I'd think of it in terms of testing for global failures before we process the spool, and then simply abandoning the whole spool job if it's not going to be possible.

No idea how you'd test for global failure or misconfiguration. Maybe we could send the site owner a test email.

But even then, I don't think we should be retrying automatically. What happens if a job fails because of a misconfiguration that doesn't get fixed for a year, we don't want that newsletter to suddenly get sent a year later. I'd suggest flush the spool if we detect a global failure, scream for admin support, and mark the send as cancelled.

If someone fixes the global failure, it's their responsibility to resend newsletters if and when they want to - we can't assume when or if they will want that.

adamps’s picture

Issue summary: View changes

OK please see updated IS and let me know what you think.

jonathanshaw’s picture

Plan looks good to me.

jonathanshaw’s picture

retry/discard

I'm not sure discard is the right user-facing terminology. It could be understood to mean discard the email addresses.

In fact, do we need a discard button at all? The emails don't do any harm sitting in the spool and will get cleaned up automatically.

adamps’s picture

Issue summary: View changes

I wonder if some admins might prefer to turn off automatic expiry and clear them by hand and probably it's not that hard to add. However we can review progress as things go along and skip it if needs be. I changed 'discard' to 'clear'.

jonathanshaw’s picture

Show a count of failed mails on /admin/config/services/simplenews/settings/mail with two buttons: retry and clear.

I'm not really sure there's a value to this.

Looking at how the bits of the IS relate, maybe the correct division is:

Base function:

  1. Create a boolean config setting "retry emails". If FALSE, a failed email is moved to new state SpoolStorageInterface::STATUS_FAILED.
  2. Once an issue has completed sending except for errors, move it to new state SIMPLENEWS_STATUS_SEND_ERRORS.

Newsletter UI:

  1. Add UI (NodeTabForm and SendStatus) to show the count of failed mails per newsletter.
  2. In state state SIMPLENEWS_STATUS_SEND_ERRORS, behaviour of actions is SendIssue => retry; StopIssue => clear.
  3. Add UI for this state on to NodeTabForm with two buttons retry/clear.

Automatic cleanup:

  1. Create an integer config setting "max spool age". Emails in the spool are discarded after this number of days provided that they have had at least one attempt at sending.

Just an idea, feel free to ignore.

adamps’s picture

Issue summary: View changes

Yes it's useful to discuss this. One of our objectives is to start off with a minimal implementation and build up.

I see two essential aspects: prevent retries, and discard errors after a certain number of days. In normal cases these two together mean that a site will run unattended without creating major problems. The main hassle for the site admin is handling bounces - either via email (NDR) or a direct synchronous error (could be automated via #3053756: Automatically block bouncing subscriber).

I guess as a first step I can get this part working and then we can re-evaluate.

Normally the site-admin will not want to retry, but they would need to if there was a failure in the entire mail transport (although the "test email" idea could bypass that problem). The simplest to implement is a global retry button. Can you think of any circumstances where someone would want to control retries on a per newsletter basis?

So far we have not touched the per-newsletter UI at all. With the existing UI, the newsletter would be stuck on 47/51 which would imply 4 errors. The fixes to the newsletter UI will roughly double the time taken. Allowing retries per newsletter requires most or all of the per-newsletter UI.

jonathanshaw’s picture

I see two essential aspects: prevent retries, and discard errors after a certain number of days

I only see prevent retries as strictly essential. Discarding errors is nice, but not vital: an ever swelling mail spool is already a minor problem, it's not actually worsened by preventing retries as most of those are already sitting there in the spool anyway failing forever.

Normally the site-admin will not want to retry, but they would need to if there was a failure in the entire mail transport

Ah, yes, I'd overlooked the issue of recovering from a failure in the entire mail transport. It's one of those things that's not exactly important, but occasionally really annoying occasionally when you want to do it but can't. A way to resend whole newsletters would address the same issue (likewise a test email as you say), but by all means let's use the simplest approach.

Can you think of any circumstances where someone would want to control retries on a per newsletter basis?

No. It's certainly not within the 80% use case, more like 99.9%.

adamps’s picture

Discarding errors is nice, but not vital

True.

So far we've not mentioned any way for the site-admin to see the failures (currently they would have to search the logs). This could be useful to block/delete a subscription that always fails. Combining this with the UI for retrying, perhaps what we want is a new view of all failed mail spool with retry/clear task buttons?

I had imagined this would be simple as the views code for mailspool exists, but I just tested and it totally fails with "Broken/missing handler". I'll raise an issue and take a quick look how hard it is to fix.

jonathanshaw’s picture

perhaps what we want is a new view of all failed mail spool with retry/clear task buttons

I'm not sure this is really satisfies any use case.

There's really 2 things that are ideal:

1) detect when whole newsletters have failed, and allow manual resending once problem is fixed if newsletter is still relevant

2) handle individual email errors automatically and intelligently as per #3053756: Automatically block bouncing subsciber

Now I frame it like this I suggest:

A) stop retries. No setting, just stop it. Retrying every failure is never the right solution. Adding back intelligent retries depending on the exact failure can be a seperate feature request.

B) count the failures as we go. If all mails in a newsletter fail, put the newsletter into a failed state where the send action flushes the failures from the spool and respools the newsletter.

C) if only some mails in a newsletter failed throw an event for each individual mail failure. This allows custom, contrib and future feature requests to handle intelligent retries, spool clean up, automatic unsubscribe, whatever.

adamps’s picture

Good discussion, I think we are improving our ideas step by step.

NB the terminology is that a newsletter is a config entity and what are talking about here is a newsletter issue (a node) - I am trying to teach myself to use the right word:-)

So there are two categories of error:

  • Global - the mails are not leaving our server. These should typically be retried once the problem is fixed. In fact to insist on trying to send 10k mails is a waste of time and instead the cron run should probably be aborted in case of global error.
  • Specific mailbox - problem reported by destination MTA. These should not be retried. The subscriber should potentially be blocked from any future issue, but we ought to distinguish soft and hard bounces - e.g. mailbox full is a soft bounce that could later be resolved.

The focus of this issue is to stop retries of mailbox errors. The problem is that we don't easily know which category an error is. We could try and figure it out with:

  1. "test send" - if that works then following errors are mailbox
  2. your option (B) which is observing a pattern so that if all mails on an issue fail it is likely (but not guaranteed!) to be global; more practical instead to say if all mails on a given cron run fail; slightly tricky to code because you only find out the answer at the end of the cron run
  3. enhancing the error reporting so the transport optionally can indicate the category - simplenews patch to set $message['error_category'] (core doesn't have to be patched although it would be desirable to do so to document the setting)

Proposal

So perhaps we need a new config setting for configuring detection of mailbox errors. By default it is disabled (all global) then there could be multiple different strategies as above. We can start with just one strategy.

Replies to your comments

stop retries. No setting, just stop it. Retrying every failure is never the right solution.

As above, global failure retires seem fine. Each failed retry will not contribute to blacklist because no message is sent.

In any case from a maintainer perspective, I think I would need to require that we keep an option to preserve the existing behaviour. If a site uses the default PHP mailer then all errors are global, in which case the current behaviour of always retrying is basically fine.

B) If all mails in a newsletter fail, put the newsletter into a failed state where the send action flushes the failures from the spool and respools the newsletter.

As above, the existing simplenews behaviour seems reasonable - automatically send the mails once the transport has been fixed.

Once again I'm not sure why this needs to be manually done per issue. It's true that if the global failure was unnoticed for a long time then some issues might no longer be relevant. However that case could be handled by cancelling them.

C) if only some mails in a newsletter failed throw an event for each individual mail failure.

Yes definitely - don't retry; delete the spool entry and allow the issue to be marked as completed with statistics such as 45/48.

adamps’s picture

Issue summary: View changes
adamps’s picture

Issue summary: View changes
adamps’s picture

Issue summary: View changes
jonathanshaw’s picture

Sorry for not replying sooner Adam, not sure how I spaced out here.

The IS proposal seems fine to me. It would be helpful to distinguish must-have (this issue) from related follow-ups.

adamps’s picture

Thanks.

The IS proposal seems fine to me. It would be helpful to distinguish must-have (this issue) from related follow-ups.

Bed pardon I don't understand what you are asking. With the new proposed resolution I am not proposing any follow-ups. Provided that the site has a "Test email address" it should work well. There is no need for manual retry, and no complicated new status on the newsletter issue - it will simply be 47/51 i.e. 4 failed and you find the failures in the log. Does that answer your question?

I guess there is one follow up I could raise. We could try to get the hook into core as it would be useful beyond just simplenews. However core patches tend to use up a lot of time:-)

jonathanshaw’s picture

I meant: does this all need doing as a single MVP; or are there stages that can stand on their own. For example, I wondered if the test address hook implementation should be a stage 2.

Also, the proposal makes it relatively hard for sites to have the current behavior of retries of failures. They'd have to unset/chase the hook implementation provided by this module? I'm OK with that, but you've been more concerned about it in past comments. Maybe you intended another config option but didn't spell it out in the IS.

adamps’s picture

For example, I wondered if the test address hook implementation should be a stage 2.

I guess the problem is how do I test it without at least one implementation. I think we've reduced the complexity to the point that it's sufficiently simple to be hard to divide into stages. I can report back with a patch without tests as the next step for review.

Also, the proposal makes it relatively hard for sites to have the current behavior of retries of failures.

Yes this is important and was fixed with a recent IS edit. If they leave the test address unset, which is the default, then they get the current behaviour. So it's fully BC.

jonathanshaw’s picture

This module provide a hook implementation based on a "known-good" test address. Create a config option "Test email address". If set, the hook sends a test message to this address. If the test message fails, the error is global otherwise it is specific.

So

  1. We don't default failures to 'specific' unless the config option "Test email address" is set.
  2. We retry failures if we don't know whether they are global or specific, but not if we know they are one or the other.
  3. In order to disable retries, provide a "test email address"

That works fine, and meets my needs, so I'm fine with it.

But it also seems rather twisty.

Wouldn't it be much kinder to future souls to:
Default failures to be 'specific' unless a hook declares them 'global'.
Provide an option "Retry failed emails" and default if to false; use an update hook to set it to true for existing installs.
Only retry failures if the setting is true and the failure is specific.

adamps’s picture

In order to disable retries, provide a "test email address"

Or implement the hook or install a different module that implements it. It doesn't really feel twisty to me - give the module a way to know whether to retry and it will retry intelligently. We could put a warning on the status page if there are no hook implementations.

Wouldn't it be much kinder to future souls to:

I'm a bit surprised because this seems to be heading back to problems we had just avoided.

Only retry failures if the setting is true and the failure is specific.

Surely we never want to retry specific failures - that's when we risk blacklisting? It's global ones that we need to retry.

"Retry failed emails" and default if to false; use an update hook to set it to true for existing installs.

So I agree this gives BC in the sense that existing sites aren't changed. However it changes the behaviour for newly installed sites in what seems a bad way. The default behaviour becomes not to retry but discard. However this seems wrong for the default PHP transport where retries are fairly harmless. Discarding might be hard to detect and difficult to recover from (newsletters don't allow resending). I can't see that people want mails to be discarded when they have some global config error.

jonathanshaw’s picture

I'm happy for you to pursue the solution you've outlined if you're comfortable with it.

I did think of another alternative whose logic seems slightly more direct to me, though may not have all the features you'd like:

- add a 'configurationValidated' property to somewhere like SpoolList or SpoolStorage, defaulted to FALSE
- before sending a batch of mails, send the test mail if configured to do so, and set configurationValidated to TRUE if successful
- delete failures (so no retries) if configurationValidated is TRUE
- if it's important to allow custom ways to assess configurationValidated ( I doubt it is, could be a follow-up), then add a setConfigurationValidated method and create an event or hook at the right point in the spool life cycle to allow setting it.

adamps’s picture

As an optimisation of your suggestion, I think it would be fine to wait until the first error before sending a test mail. So I think that what you are describing is effectively the same as what I described with the addition of caching of the result of sending a test mail. Yes I had in mind that we might want to do that.

The caching could be done within the hook as a static variable. We might want to be slightly more subtle and for example retry the test after every N failures. We could also reset the failure count for every successful send - in which case we'd better also call the hook for success.

I think these are all details that would be fairly easy to adjust later on. Let me send you a basic patch that's working and it should be easier to discuss it with that context.

adamps’s picture

Version: 8.x-1.x-dev » 8.x-2.x-dev
Issue tags: +Plan to commit

Let's try to get this in before 2.x beta.

adamps’s picture

Status: Active » Needs review
StatusFileSize
new10.37 KB

Here is a partial patch that adds the new hook but without providing the implementation.

I ended up doing some refactoring of Mailer::sendSpool as it was a bit baffling and contorted from the history. I imagine that at first it was coded to send all the mails and then process results. Later it was discovered that this caused duplicate mails in the cases of PHP timeouts. In the checked-in version, the block of code commented "Update spool status" is duplicated (very nearly). I need to alter that code around block. This block of code is a loop and at first it is not obvious why, or even more odd, why it need to be there a second time. The answer is that SpoolList::nextMail can fail to load some subscribers before returning one. With hindsight now I have grasped everything I probably could have done it without refactoring, but the new code seems much clearer so I propose to keep it.

What is actually going on is:

  1. Must run $this->spoolStorage->updateMails immediately for each mail else we get duplicates in case of timeout. Let's have a new function SpoolList::setResult that does this cleanly and clearly one mail at a time.
  2. Update subscriber count is done once at the end. Therefore no point in doing "Update subscriber count." before the end either.

This is a minor BC break, but 8.2 is still an alpha. SpoolStorage::getMails has a hard-coded call to create SpoolList. However someone could have overriden that with their own function that creates a different class. It's probably not a very likely thing to have done and the required action to fix is pretty simple.

I would welcome any review comments at this stage, plus I'd like to check the tests still all pass.

adamps’s picture

adamps’s picture

Issue summary: View changes
adamps’s picture

adamps’s picture

adamps’s picture

adamps’s picture

Issue summary: View changes
adamps’s picture

Issue summary: View changes

OK this is ready for preliminary review please. Please see the updated IS for explanation and status (see list of remaining tasks).

I am wondering about going a step further on the SpoolList enhancements. We could move the code for counting error/fail/skip into SpoolListInterface and pass SpoolListInterface though to the the new hook. This would allow a decision to be made on the basis of cumulative results.

Another point is that so far there is nowhere in the UI that indicates the number of errors for sending an issue - possibly we should add that?

jonathanshaw’s picture

I can't speak to whether the spool refactoring works, but congratulations on taming the voodoo if it does, that's quite dark stuff.

  1. +++ b/simplenews.api.php
    @@ -225,3 +226,15 @@ function hook_simplenews_source_cache_info() {
    + * Invoked after sending of every mail to allow altering of the result.
    + *
    + * @param array $result
    + *   Message result in the format returned by
    + *   Drupal\simplenews\Mail\MailerInterface::sendMail.
    + * @param \Drupal\simplenews\Mail\MailInterface $mail
    + *   The mail that was sent.
    + */
    +function hook_simplenews_mail_result_alter(array $result, MailInterface $mail) {
    

    I suggest expanding these docs to explain the need both to throw the abort exception if they want to cancel sending, and that the hook is responsible for any logging or cleanup required in the case of an abort. Possibly also some example use cases might help to reassure implementers that they're using the right tool for the job.

  2. +++ b/src/AbortSendingException.php
    @@ -0,0 +1,13 @@
    +namespace Drupal\simplenews;
    

    Consider creating an 'Exception' directory to organise these as there may be more in future?

  3. +++ b/src/Mail/Mailer.php
    @@ -367,13 +355,14 @@ class Mailer implements MailerInterface {
    +        // By default errors are retried indefinitely.
    

    You previously decided to keep this behavior, for BC. But at that time you were not yet releasing a new major version.

    Given that we know this is significantly dangerous in one of the most common configurations, maybe it would be kinder to make this safe OOTB in 2.x.

    If people want to take risks with retries and know what they are doing, they can use the hook to implement that.

  4. +++ b/src/Mail/MailerInterface.php
    @@ -51,8 +51,10 @@ interface MailerInterface {
    +   *   Array indicating status of sending with properties
    +   *   - error: TRUE if sending failed.
    +   *   - status: A simplenews spool status to indicate the status.
    

    Might be helpful to explain more:
    - these keys are both required / reliably present
    - error is a boolean (i.e. won't be null)
    - status must be a SpoolStorageInterface status constant, not null.

  5. Lastly, I'm concerned that the result array passed into the new hook might not be informative enough for all purposes like proper bounce handling or unsubscribing. All it really tells us of whether there was an error and simplenews's verdict of skipped, pending or done. Maybe we should add optional keys such as:
    - 'mail_result' with the result of mail()
    - 'exception' with any caught exception
    It's possible this is getting complex enough that it's worth considering having a Result object for this job but it's certainly not necessary.
adamps’s picture

Thanks @jonathanshaw. The major point for discussion is 3.

1. Yup (depending on answer to 3 if we still keep this hook). Probably better if we put the logging and cleanup into the core code. I will likely write an implementation in any case which can serve as an example.

2. Moving the exception is not BC and would require code updates from anyone who has written code to throw it. True we can make non-BC changes now, but we should try to minimise them and I don't feel this one is worth it.

3. Good point. However the current idea of using a test email address cannot be the default because it requires the site admin to configure an address. We could switch to one of the other ideas and I think the most likely one is this:

If a fail is followed later in the same spool run by a success then count it as a specific error.

It's definitely easier to use if there isn't a new config setting. We could get stuck with a set of spool entries that all repeatedly fail and no way to tell what type of error it is - but that feels like an edge case. So my feeling is that we start off by keeping it simple. There's nothing stopping us from later adding the test address in addition.

4. Will do.

5. Pending answer to 3.

jonathanshaw’s picture

I think your idea for 3 is good.

I wonder if the hook is worth having anyway e.g. for bounce management / unsubscribing with swiftmailer

adamps’s picture

Issue summary: View changes
Issue tags: +Needs tests
StatusFileSize
new23.11 KB

OK so now we don't have to be BC we can simplify matters.

The result array with an int and a bool really isn't easy to code with or to understand. A single int is sufficient and much clearer. I have added a new constant SpoolStorageInterface::STATUS_FAILED which removes the need for the extra boolean.

Retrying forever is not really desirable for any site. The code now stores pending "unclassified" errors (STATUS_PENDING). If one of the following mails succeeds, pending errors are converted specific failures (STATUS_FAILED). If there are too many pending errors, then the spool run is aborted (AbortSendingException).

If the reviewers are in favour of this idea then I will tidy up further as listed in "remaining tasks".

NB #36.5 - there is no further information available. There is no caught exception. The result of $mail is already passed as the $message parameter.

adamps’s picture

adamps’s picture

Issue summary: View changes
adamps’s picture

adamps’s picture

Issue summary: View changes
adamps’s picture

adamps’s picture

OK this is ready for another review please.

The latest patch is a bigger hit to BC, but it's a win for the future by simplifying a lot of unnecessary complications. The "double return parameter passed as array" was ugly to code with and tended to be buggy (e.g. the old statistics counting in Mailer::sendSpool() could count some rows twice and some not at all).

jonathanshaw’s picture

  1. +++ b/simplenews.install
    @@ -44,20 +44,13 @@ function simplenews_schema() {
    +        'description' => 'The sent status of the email (0 = hold, 1 = pending, 2 = done, 3 = locked, 4 = skipped, 5 = failed).',
    
    +++ b/simplenews.views.inc
    @@ -144,7 +144,7 @@ function simplenews_views_data() {
    +    'help' => t('The sent status of the email (0 = hold, 1 = pending, 2 = done, 3 = locked, 4 = skipped, 5 = failed).'),
    
    +++ b/src/Spool/SpoolStorageInterface.php
    @@ -30,10 +30,15 @@ interface SpoolStorageInterface {
       const STATUS_IN_PROGRESS = 3;
    

    There's an inconsistency here AFAICS between "locked" and "IN_PROGRESS"

  2. +++ b/src/Mail/Mailer.php
    @@ -216,69 +227,47 @@ class Mailer implements MailerInterface {
    +        else {
               $count_fail++;
    

    As I understand it, this will catch hold, pending, and in_progress and increment $count_fail, which further down we report in the log as "failed". I wonder if it would help debugging to count and report these seperately.

  3. +++ b/src/Mail/Mailer.php
    @@ -359,27 +348,11 @@ class Mailer implements MailerInterface {
    +      $result = $message['result'] ? SpoolStorageInterface::STATUS_DONE : SpoolStorageInterface::STATUS_PENDING;
    

    The semantics of PENDING are not obvious; I suggest a comment as to what $message['result'] being false means and why we respond to this with PENDING.

  4. +++ b/src/Spool/SpoolList.php
    @@ -78,29 +85,56 @@ class SpoolList implements SpoolListInterface {
    +  public function setResult($result) {
    

    I don't know if this matters, but arguably the name suggests this is a setter for a "result" property on the SpoolList.

  5. +++ b/src/Spool/SpoolList.php
    @@ -78,29 +85,56 @@ class SpoolList implements SpoolListInterface {
    +      $this->spoolStorage->updateMails([$spool_data->msid], $result);
    +
    +     if (($result == SpoolStorageInterface::STATUS_DONE) && !empty($this->pendingErrors)) {
    +        // We have successfully sent a mail so there is not a global transport
    +        // error.  Therefore any pending errors are specific problems.
    +        $this->spoolStorage->updateMails($this->pendingErrors, SpoolStorageInterface::STATUS_FAILED);
    +        $this->pendingErrors = [];
    +      }
    

    If you continued to allow updateMails to take an array as its second argument, we could combine these calls, and might we save a database write?
    e.g.

    $messages = [$spool_data->msid];
    $results = [$result];
     if (($result == SpoolStorageInterface::STATUS_DONE) && !empty($this->pendingErrors)) {
            $messages += $this->pendingErrors;
            $results += array_fill(0, count($this->pendErrors), SpoolStorageInterface::STATUS_FAILED)); 
    }
    $this->updateMails($messages, $results);
    
  6. +++ b/src/Spool/SpoolList.php
    @@ -78,29 +85,56 @@ class SpoolList implements SpoolListInterface {
       public function getProcessed() {
    

    I wonder if this should be called getResults as it is no longer a getter for the processed property, but something more complex. "results" seems to be more descriptive and match the terminology elsewhere.

  7. +++ b/src/Spool/SpoolListInterface.php
    @@ -8,30 +8,29 @@ namespace Drupal\simplenews\Spool;
    +   *   One of the SpoolStorageInterface::STATUS_* constants.
    ...
    +   *     - result: value from setResult()
    

    You said it well above:
    "One of the SpoolStorageInterface::STATUS_* constants."

  8. +++ b/src/Spool/SpoolListInterface.php
    @@ -8,30 +8,29 @@ namespace Drupal\simplenews\Spool;
    +   *   An array of mail spool rows. Includes the following additional
    +   *   properties.
    

    I suggest "An array of mail spool rows. Each array value is itself an array with keys:"

  9. 	   /**
    -   * Pending.
    +   * Pending or retrying failure.
        */
       const STATUS_PENDING = 1;
    
  10. A nit, but clarifying:
    "Pending, or retrying failure."

  11. Lastly, I've got no grip on the refactoring you've done and it's clear that the code base was designed to handle some quite arcane edge cases with timeouts etc. You might want to make sure you are satisfied with the existing test coverage of these edge cases.
adamps’s picture

Thanks @jonathanshaw. Done except where noted:

2. This code hasn't really changed from before in semantics. The preceding code has covered DONE and SKIPPED. If it's set to any other state, it seems it can only be because there was an error. The likely cases are STATUS_FAILED or STATUS_PENDING (= retrying failure), which I think can both reasonably be counted as failed. I added a comment. STATUS_HOLD and STATUS_IN_PROGRESS are not really expected at this point, but even if they do occur from the hook they tend to indicate that there will eventually be a retry so our best guess is that the first try didn't work.

5. The original code has a single efficient database write setting all the keys to the same value. Your proposal would make it more complex and less efficient by passing an array of values.

8. I have improved the comment. Note that the keys listed are extra ones in addition to the mail spool row keys.

10. I think our main duty here is to write tests for the new code paths. Once that's done we can reasonably expect future developers to keep our new code paths working.

We are fixing a valid issue causing real problems. It was difficult to fix the issue without some refactoring of code (which we could say was surprising/confusing/unhelpful in places). I have done my best to intuit what was in the minds of the original coders, and keep that working but sometimes it's hard to tell. If previous developers had scenarios that they considered important to generate a particular result, then obviously they had the option to write a test.

However if you are keen that this work includes back-filling missing tests for existing function then let's discuss that off-line.

adamps’s picture

adamps’s picture

New patch counting and displaying of errors.

Also I have altered the heuristic to classify unknown errors. If any single mail in the list succeeds, then we now assume the mail transport is good and all errors are specific failures. The previous approach seemed too likely to leave a few mails stuck retrying.

adamps’s picture

Issue summary: View changes
adamps’s picture

Changes in this patch:

  • Fix test failures and coding standards
  • Add example code in the hook
  • Update hook to remove the simplenews_mail_spool error property
adamps’s picture

adamps’s picture

adamps’s picture

adamps’s picture

OK I've done everything on my list and the patch is green so I'm ready to commit. I will wait 1 week in case anyone has any comments.

I didn't upload an "only tests" patch as it seems pretty obvious that the tests can't possibly pass on their own.

adamps’s picture

Status: Needs review » Fixed
Issue tags: -Plan to commit

  • AdamPS committed 762d391 on 8.x-2.x
    Issue #3052714 by AdamPS, jonathanshaw: Send failures retried forever
    

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.