fix(messaging): skip mail input no provider can deliver instead of retrying by ChiragAgg5k · Pull Request #13831 · appwrite/appwrite · GitHub
Skip to content

fix(messaging): skip mail input no provider can deliver instead of retrying - #13831

Merged
ChiragAgg5k merged 4 commits into
mainfrom
fix/messaging-invalid-input
Sep 22, 2026
Merged

ChiragAgg5k merged 4 commits into
mainfrom
fix/messaging-invalid-input

Conversation

@ChiragAgg5k

@ChiragAgg5k ChiragAgg5k commented Sep 22, 2026 •

Copy link
Copy Markdown
Member

Why

Resend answered Invalid to field for 269 cloud mails in two weeks (CLOUD-3QX0). Every one reached the provider, came back as a generic Error sending mail: … with code 500, was retried by the queue, and landed in Sentry as an error, though nothing about the input could change.

utopia-php/messaging 2.4.0 (utopia-php/monorepo#321) now refuses such input with Utopia\Messaging\Exception\InvalidArgumentException, whose getType() names the reason and getValue() carries the offending input. This PR takes that release and handles the exception where mail is sent.

The 500s this removes

Each scenario below is a real shape from the Sentry sample. Before, all of them took the same path in the Mails worker:

} catch (\Throwable $error) {
    Span::add('mail.status', 'failure');
    throw new Exception('Error sending mail: ' . $error->getMessage(), 500);
}

1. Recipient domain that cannot receive mail (c.c, a.i, gyung.me976153, about 8% of events). A user signs up with a junk address and triggers a session alert or OTP. The message now fails to build before any request is made:

new EmailMessage(to: [['email' => 'john@c.c', 'name' => 'John']], ...);
// InvalidArgumentException, type recipient_domain_invalid, value john@c.c

2. Display name that breaks the address header (gmail.com cases, real users). A name such as Doe, John <JD> was interpolated unquoted into Name <email>, so the provider saw a malformed to. The adapter now quotes it per RFC 5322 and the mail is delivered:

// Resend payload before: "Doe, John <JD> <john@gmail.com>"   -> 422
// Resend payload now:    "\"Doe, John <JD>\" <john@gmail.com>" -> delivered

A name carrying a line break, which would inject a header, is refused at construction with type name_malformed.

3. Reserved documentation domains (example.com, about 90% of events). Developers testing with fake users. Resend blocks these by policy with a 422. The adapter maps that to the typed exception instead of a delivery failure, and the worker skips it:

$adapter->send($message);
// InvalidArgumentException, type provider_rejected, value john@example.com,
// message "Invalid `to` field. The email address needs to follow …"

4. Malformed sender or reply-to from a project's custom SMTP options, previously a 500 on every mail for that project until someone noticed. Now sender_malformed at construction.

What changes in the workers

Workers\Mails moves message construction into the try and adds one catch ahead of the generic one. A skipped mail is recorded on the span and the job completes, so the queue does not redeliver it and Sentry does not see it:

} catch (InvalidArgumentException $error) {
    Span::add('mail.status', 'skipped');
    Span::add('mail.error', $error->getType());
    Span::add('mail.error.message', $error->getMessage());

    return;
} catch (\Throwable $error) {
    // unchanged: transient provider failures still throw and retry

Workers\Notifications does the same for the email channel, returning null so no alert row is persisted for a mail that never went out:

} catch (InvalidArgumentException $error) {
    Span::add('email.skipped', $error->getType());
    Span::add('email.error', $error->getMessage());

    return null;
}

Workers\Messaging (the Messages API) builds one provider message per batch, so one undeliverable address must not cost the rest their send. The build now runs in a loop that records the offending recipient as a terminal error, drops it, and rebuilds for the others:

} catch (InvalidArgumentException $e) {
    $recipient = $e->getValue();
    $this->recordError($errors, "Failed sending to target {$recipient} with error: {$e->getMessage()}");
    $pending = \array_values(\array_diff($pending, [$recipient]));
}

A typed exception thrown by the send itself (a provider 422) is terminal regardless of what isRetryableError() would say about its text.

Tests

  • MailsTest: a recipient at john@c.c never reaches the adapter and the worker returns; an adapter throwing provider_rejected is not retried and does not throw.
  • NotificationsTest: an undeliverable address never reaches SMTP and leaves no notifications row.
  • AppwriteTest (push adapter): the base adapter's message-type rejection is now the typed exception, asserted by class rather than message text.

The new worker tests error against the previous worker code. There is no unit seam for Messaging::retrySend() without a database, so that change is covered by the messaging package's own tests and by review.

@greptile-apps

greptile-apps Bot commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Comment thread src/Appwrite/Platform/Workers/Messaging.php
Comment thread tests/unit/Platform/Workers/MailsTest.php
Comment thread tests/unit/Platform/Workers/NotificationsTest.php Outdated
@github-actions

github-actions Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

✨ Benchmark results

Comparing main (before) → fix/messaging-invalid-input (after).

Metric Before After Change
🚀 Requests/sec 189.57 204.77 🟢 +8%
⏱️ Latency P50 90.79 ms 84.97 ms 🟢 -6.4%
⏱️ Latency P95 214.02 ms 193.18 ms 🟢 -9.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 84.97 193.18 12,711 204.77 -20.84
Account 158.87 297.6 669 11.05 -26.94
TablesDB 81.09 147.97 6,913 112.88 -18.09
Storage 81.57 176.1 3,345 56.38 -4.55
Functions 122.16 236.08 1,784 30.71 -15.25

Top API waits (after)

API request Max wait (ms)
functions.create 503.38
account.prefs.update 486.32
functions.variables.update 474.01
storage.files.create 455.58
storage.files.preview 400.57

@ChiragAgg5k

Copy link
Copy Markdown
Member Author

@greptile review

@ChiragAgg5k

Copy link
Copy Markdown
Member Author

Comment thread tests/unit/Appwrite/Messaging/Adapter/Push/AppwriteTest.php
@ChiragAgg5k
ChiragAgg5k merged commit 6114e86 into main Sep 22, 2026
97 of 98 checks passed
@ChiragAgg5k
ChiragAgg5k deleted the fix/messaging-invalid-input branch September 22, 2026 13:41
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.

2 participants