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:
- If at least one mail in the list mails succeeds, any errors are converted specific failures
- 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.
- Global error: throw AbortSendingException to abort the current cron run, retry on next cron.
- Specific error: set status to
STATUS_FAILEDmeans 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
- Fix various inaccurate/confusing comments and variable names
- Fix description on the admin page /admin/config/services/simplenews/settings/mail which is too specific to the default PHPMailManager->mail().
- 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.
- Create SpoolStorage::createSpoolList to make it easy to use a custom SpoolList class.
Older options
Minimum function:
- Prevent retries: create a boolean config setting "retry emails". If FALSE, a failed email is moved to new state SpoolStorageInterface::STATUS_FAILED.
- 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:
- Add UI (NodeTabForm and SendStatus) to show the count of failed mails per newsletter.
- Once an issue has completed sending except for errors, move it to new state SIMPLENEWS_STATUS_SEND_ERRORS.
- In this state, behaviour of actions is SendIssue => retry; StopIssue => clear.
- Add UI for this state on to NodeTabForm with two buttons retry/clear.
Comments
Comment #2
jonathanshawAgreed. 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.
Comment #3
adamps commentedI 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".
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
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.
Comment #4
jonathanshawA worthy goal, but I think we should focus here on what to do from simplenews perspective assuming it's never improved upstream.
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.
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.
Comment #5
adamps commentedOK please see updated IS and let me know what you think.
Comment #6
jonathanshawPlan looks good to me.
Comment #7
jonathanshawI'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.
Comment #8
adamps commentedI 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'.
Comment #9
jonathanshawI'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:
Newsletter UI:
Automatic cleanup:
Just an idea, feel free to ignore.
Comment #10
adamps commentedYes 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.
Comment #11
jonathanshawI 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.
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.
No. It's certainly not within the 80% use case, more like 99.9%.
Comment #12
adamps commentedTrue.
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.
Comment #13
jonathanshawI'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.
Comment #14
adamps commentedGood 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:
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:
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
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.
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.
Yes definitely - don't retry; delete the spool entry and allow the issue to be marked as completed with statistics such as 45/48.
Comment #15
adamps commentedComment #16
adamps commentedComment #17
adamps commentedComment #18
jonathanshawSorry 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.
Comment #19
adamps commentedThanks.
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:-)
Comment #20
jonathanshawI 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.
Comment #21
adamps commentedI 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.
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.
Comment #22
jonathanshawSo
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.
Comment #23
adamps commentedOr 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.
I'm a bit surprised because this seems to be heading back to problems we had just avoided.
Surely we never want to retry specific failures - that's when we risk blacklisting? It's global ones that we need to retry.
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.
Comment #24
jonathanshawI'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.
Comment #25
adamps commentedAs 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.
Comment #26
adamps commentedLet's try to get this in before 2.x beta.
Comment #27
adamps commentedHere 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:
$this->spoolStorage->updateMailsimmediately 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.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.
Comment #28
adamps commentedComment #29
adamps commentedComment #30
adamps commentedComment #31
adamps commentedComment #32
adamps commentedComment #33
adamps commentedComment #34
adamps commentedComment #35
adamps commentedOK 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?
Comment #36
jonathanshawI can't speak to whether the spool refactoring works, but congratulations on taming the voodoo if it does, that's quite dark stuff.
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.
Consider creating an 'Exception' directory to organise these as there may be more in future?
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.
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.
- '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.
Comment #37
adamps commentedThanks @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.
Comment #38
jonathanshawI think your idea for 3 is good.
I wonder if the hook is worth having anyway e.g. for bounce management / unsubscribing with swiftmailer
Comment #39
adamps commentedOK 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.
Comment #40
adamps commentedComment #41
adamps commentedComment #42
adamps commentedComment #43
adamps commentedComment #44
adamps commentedComment #45
adamps commentedOK 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).Comment #46
jonathanshawThere's an inconsistency here AFAICS between "locked" and "IN_PROGRESS"
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.
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.
I don't know if this matters, but arguably the name suggests this is a setter for a "result" property on the SpoolList.
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.
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.
You said it well above:
"One of the SpoolStorageInterface::STATUS_* constants."
I suggest "An array of mail spool rows. Each array value is itself an array with keys:"
A nit, but clarifying:
"Pending, or retrying failure."
Comment #47
adamps commentedThanks @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.
Comment #48
adamps commentedComment #49
adamps commentedNew 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.
Comment #50
adamps commentedComment #51
adamps commentedChanges in this patch:
Comment #52
adamps commentedComment #53
adamps commentedComment #54
adamps commentedComment #55
adamps commentedOK 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.
Comment #56
adamps commented