fix(mails): finish the job when a project's own SMTP server fails by ChiragAgg5k · Pull Request #14139 · appwrite/appwrite · GitHub
Skip to content

fix(mails): finish the job when a project's own SMTP server fails - #14139

Merged
ChiragAgg5k merged 1 commit into
mainfrom
fix/mails-project-smtp-failures
Oct 5, 2026
Merged

ChiragAgg5k merged 1 commit into
mainfrom
fix/mails-project-smtp-failures

Conversation

@ChiragAgg5k

@ChiragAgg5k ChiragAgg5k commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

Why

When a project sends mail through its own SMTP server and that server fails, the Mails worker throws. The queue then puts the job on the failed list (utopia-queue.failed.v1-mails), as if the worker had broken.

The cause is on the customer's side. The SMTP server rejects the login, or the host can't be reached. A retry would hit the same server with the same credentials, and nothing retries this list automatically anyway. The failed list only grows, and it buries real failures. It also keeps the Queue has failed jobs alert firing on fra1 mails around the clock.

Production, last 24h:

Region Project SMTP (401) Cloud SMTP (500)
fra1 621 0
sfo3 19 0
nyc3 5 0
sgp1 5 0
syd1 0 1

A 6h sample of the fra1 project-SMTP errors:

No SMTP host answered: smtp.mailersend.net:587 (Expected 220, the server said: 554 Too many failed …)   132
No SMTP host answered: smtp.mailersend.net:587 (Authentication failed: 535 Too many failed …)          76
No SMTP host answered: smtp.mailersend.net:587 (Authentication failed: 535 Authentication …)           21
530 Error: authentication Required                                                                      10
No SMTP host answered: s102.cyber-folks.pl:465 (Cannot reach …)                                          7
No SMTP host answered: smtp.stackmail.com:587 (Authentication failed: 535 Incorrect authen…)            7
No SMTP host answered: smtp.protonmail.ch:587 (Authentication failed: 535 5.7.8 …)                      5

Only project SMTP is affected

$type is smtp only when the payload carries an smtp config, and every publisher fills that from the project's own settings:

  • Account, MFA, membership and session-alert mails build smtp from $project->getAttribute('smtp'), and only when enabled is true. Otherwise it stays [] and the mail goes through _APP_SMTP_HOST, which is our cloud SMTP and still throws.
  • Project/SMTP/Tests/Create (the console's "send test email") uses the host and credentials the user entered.
  • Cloud's Mails override doesn't touch smtp.

In production, all 649 smtp failures in the last 24h came from 15 customer projects. One project with locked-out mailersend credentials accounted for 557 of them. None came from console. The single cloud failure still throws.

What

The worker already told the two cases apart: project SMTP threw with code 401 and cloud SMTP with 500. Project SMTP failures now record the error on the span and finish the job, the same way #13831 handles mail no provider can deliver. Cloud SMTP failures still throw. Those are ours, and retrying them makes sense.

} catch (\Throwable $error) {
    Span::add('mail.status', 'failure');

    if ($type === 'smtp') {
        Span::add('mail.error.message', $error->getMessage());

        return;
    }

    throw new Exception('Error sending mail: ' . $error->getMessage(), 500);
}

The failure stays visible: the span keeps mail.status: failure, mail.smtp_type: smtp and the error message.

Test

testProjectSmtpFailureIsNotRetried sends through a project SMTP server on a closed local port (127.0.0.1:1). Without the fix it throws Error sending mail: No SMTP host answered: 127.0.0.1:1 (… Connection refused), the same shape as production. With the fix the job finishes, and the cloud adapter is never used. The existing cloud-failure tests still expect a throw.

  • vendor/bin/phpunit tests/unit/Platform/Workers/MailsTest.php: 6 tests pass
  • composer lint, PHPStan and Rector (tests/tools/rector.php) pass on the changed files

Follow-up

The functions worker does the same thing for customer runtime errors: execution timeouts, startup crashes and memory kills. That needs its own PR.

Related: #14140

A project's SMTP credentials or host are theirs to fix, so retrying cannot help and the failed list only grows. Cloud SMTP failures still fail the job.
@hansi-codes

hansi-codes Bot commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

🔵 Tier A · Mergeable after minor fixes

The implementation matches the intended behavior, but the new worker test violates the project's explicit test-tier rule.

Project-owned SMTP delivery failures now record the error message on the mail span and return normally, while cloud SMTP failures continue to throw. The regression test exercises a project SMTP connection failure and checks that the cloud adapter is not used.

Verdict New comments Fixed Still open
✅ Approved 1 0 0
Finding Where
🟡 Cover the worker failure through the e2e tier tests/unit/Platform/Workers/MailsTest.php:169
Fix with agent prompt
### Issue 1
tests/unit/Platform/Workers/MailsTest.php:169-177
**Cover the worker failure through the e2e tier**

AGENTS.md explicitly forbids unit-testing workers and requires e2e coverage for queue-visible behavior. Please move this regression to the e2e tier and verify that the failed SMTP job completes without entering the failed queue; calling `Mails::action()` directly bypasses that lifecycle.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
📂 Walkthrough · 2
File Change
src/Appwrite/Platform/Workers/Mails.php Complete project SMTP failures normally after recording span diagnostics.
tests/unit/Platform/Workers/MailsTest.php Add a project SMTP failure case and allow SMTP configuration in the test helper.

Reviewed 4ab36cb · Details · Comment @hansi-codes review to re-run, or mention @hansi-codes with a question.

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Tier A · Looks good to merge. Summary

Comment thread tests/unit/Platform/Workers/MailsTest.php
@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown

✨ Benchmark results

Comparing main (before) → fix/mails-project-smtp-failures (after).

Metric Before After Change
🚀 Requests/sec 208.76 210.71 ⚪ +0.9%
⏱️ Latency P50 80.64 ms 81.57 ms ⚪ +1.2%
⏱️ Latency P95 201.75 ms 190.16 ms 🟢 -5.7%
Per-scenario breakdown & investigation details

Metrics below reflect the current branch (after). Δ P95 compares against the base.

Scenario P50 (ms) P95 (ms) Requests RPS Δ P95 (ms)
API total 81.57 190.16 13,395 210.71 -11.58
Account 158.03 305.77 705 11.67 -7.54
TablesDB 79.29 148.99 7,285 117.65 +0.57
Storage 73.12 164.8 3,525 58.2 +0.2
Functions 115.62 246.41 1,880 31.72 -3.71

Top API waits (after)

API request Max wait (ms)
account.name.update 563.61
functions.variables.update 433.84
functions.create 413.89
functions.variables.create 403.4
tablesdb.rows.create 383.71

@ChiragAgg5k
ChiragAgg5k merged commit 0160b0f into main Oct 5, 2026
51 checks passed
abnegate added a commit that referenced this pull request Oct 6, 2026
Brings in #14130 (route physical collection reads, schema checks, cache
purges and transaction commits through the databases handle), #14120's
merge commit, #14137 (VCS tag globs) and #14139 (project SMTP failures
finish the mail job).

Conflicts were resolved by keeping the database 8.0 forms the train
already uses (ColumnType cases, Capability checks via supports(),
Attribute::fromArray for checkAttribute, supportsSpatial/
supportsRelationships helpers) while taking main's databases-handle
routing, so dedicated databases get their own adapter's capabilities
and schema. The spatial capability check moves after the database
lookup because it now needs that database's adapter.

Documents Create/Update keep the train's RelationshipValues, which
already reads stored related documents through the databases handle;
main's inline permission walk is superseded by it.

Transactions increment/decrement now build their Counter from the
databases handle, since main renamed the handler parameter and the
counted collection's schema lives on that handle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant