mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 08:15:55 +00:00
Stalwart runs the SMTP RCPT stage while it creates a submission and
records a refused recipient as `delivered: "no"` with its reply: a relay
it doesn't allow ("550 5.1.2 Relay not allowed.") or a used-up throttle
("452 4.4.5 Rate limit exceeded, try again later."), on 0.16 and 1.0
alike. The create still succeeds and onSuccessUpdateEmail files the
message in Sent. Bulwark read only the new record's id and sendAt, said
"Email sent", and on Stalwart destroyed the record right after, the only
trace of the refusal. The message was lost without a bounce.
- the send reads the new submission's delivery status in the same
request (`EmailSubmission/get` by its creation id; the create's answer
has none), before the record is destroyed
- every recipient refused: the send fails with the server's reply per
address, the copy filed in Sent is removed and the user's draft kept,
so the address can be fixed in the composer; held sends (undo send,
scheduled) are refused when created, so this covers them too
- some recipients refused: the others get the message, and a warning
names the refused ones with their replies
- "Send now" and "Reschedule" keep the original when every recipient of
the replacement is refused, instead of cancelling it
- the submission prune leaves a shared account that refuses its records
alone for the session, without a warning: 0.16 lists an account that
only shared an address book or a calendar with the submission
capability
100 lines
4.7 KiB
TypeScript
100 lines
4.7 KiB
TypeScript
import { NoLongerScheduledError, RecipientsRefusedError, RequestTimeoutError, ScheduleTooLateError, SubmissionQuotaError } from '@/lib/jmap/client';
|
|
import type { RefusedRecipient } from '@/lib/jmap/types';
|
|
|
|
type Translate = (key: string, values?: Record<string, string>) => string;
|
|
|
|
/** "nobody@example.net (550 5.1.2 Relay not allowed.)", one per refused recipient. */
|
|
export function formatRefusedRecipients(recipients: RefusedRecipient[]): string {
|
|
return recipients.map((r) => (r.smtpReply ? `${r.email} (${r.smtpReply})` : r.email)).join(', ');
|
|
}
|
|
|
|
/**
|
|
* Thrown by "Undo send" and "Cancel and edit" when the cancel went through
|
|
* but a step after it failed: putting the message back in Drafts
|
|
* (`restore`), or removing the copy of an S/MIME message, which is composed
|
|
* again from scratch (`remove`). Nothing goes out; the message is still in
|
|
* Sent. Reported apart from a failed cancel, which may still let it go.
|
|
*/
|
|
export class SendCancelledCleanupError extends Error {
|
|
constructor(readonly step: 'restore' | 'remove', readonly reason: unknown) {
|
|
super(step === 'restore'
|
|
? 'Sending was cancelled, but the message could not be put back in Drafts'
|
|
: 'Sending was cancelled, but the unsent message could not be removed from Sent');
|
|
this.name = 'SendCancelledCleanupError';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* What to tell the user about a send (or reschedule) that failed, from the
|
|
* `email_composer` messages. Most errors carry the server's own description;
|
|
* these are the ones it would misrepresent or leave unexplained:
|
|
* - a full submission quota on Stalwart: it asks the user to "delete some",
|
|
* but the client has already removed every record it may, so only an
|
|
* administrator can help. Another server's overQuota may stand for any
|
|
* sending limit, so its own description goes with a generic sentence;
|
|
* - a timeout, which is not a clean failure: the submission may have reached
|
|
* the server and gone out, with only the answer lost. Saying "send failed"
|
|
* would invite a re-send and a duplicate, so point at Sent instead (#702);
|
|
* - a hold beyond the server's limit;
|
|
* - every recipient refused: the server took the submission, so nothing
|
|
* else would say that the message went nowhere. Its reply per address
|
|
* goes with it.
|
|
*/
|
|
export function sendFailureMessage(error: unknown, t: Translate): string {
|
|
if (error instanceof SubmissionQuotaError) {
|
|
return error.countsRecords
|
|
? t('submission_quota')
|
|
: t('submission_limit', { reason: error.description || error.jmapType });
|
|
}
|
|
if (error instanceof RequestTimeoutError) return t('send_timeout');
|
|
if (error instanceof ScheduleTooLateError) return t('schedule_send_too_late');
|
|
if (error instanceof RecipientsRefusedError) {
|
|
return t('recipients_refused', { recipients: formatRefusedRecipients(error.recipients) });
|
|
}
|
|
return error instanceof Error && error.message ? error.message : t('send_failed');
|
|
}
|
|
|
|
/**
|
|
* The toast for a failed "Send now" or "Reschedule", from the `email_viewer`
|
|
* (`tViewer`) and `email_composer` (`tComposer`) messages. At a full
|
|
* submission quota no replacement was made, so the message still goes out at
|
|
* its old time, `scheduledAt` (formatted) when known. Making room takes a
|
|
* while, and the original may have gone out meanwhile. When the server
|
|
* refused every recipient of the replacement, the original was kept too.
|
|
*/
|
|
export function rescheduleFailureToast(
|
|
error: unknown,
|
|
tViewer: Translate,
|
|
tComposer: Translate,
|
|
scheduledAt?: string,
|
|
): { title: string; message?: string } {
|
|
if (error instanceof NoLongerScheduledError) {
|
|
return { title: tViewer(error.sent ? 'already_sent' : 'no_longer_scheduled') };
|
|
}
|
|
const stillScheduled = scheduledAt ? tViewer('still_scheduled_at', { date: scheduledAt }) : tViewer('still_scheduled');
|
|
if (error instanceof SubmissionQuotaError) {
|
|
return { title: stillScheduled, message: sendFailureMessage(error, tComposer) };
|
|
}
|
|
if (error instanceof RecipientsRefusedError) {
|
|
return { title: sendFailureMessage(error, tComposer), message: stillScheduled };
|
|
}
|
|
return { title: sendFailureMessage(error, tComposer) };
|
|
}
|
|
|
|
/**
|
|
* The toast for a failed "Undo send" or "Cancel and edit". A failed cancel
|
|
* may still let the mail go, so it must never read as done: it gets
|
|
* `cancelFailedTitle` and the reason. One that went through while a step
|
|
* after it failed says where the message is instead.
|
|
*/
|
|
export function cancelFailureToast(
|
|
error: unknown,
|
|
tViewer: Translate,
|
|
cancelFailedTitle: string,
|
|
): { title: string; message?: string } {
|
|
if (error instanceof SendCancelledCleanupError) {
|
|
return { title: tViewer(error.step === 'restore' ? 'send_cancelled_restore_failed' : 'send_cancelled_remove_failed') };
|
|
}
|
|
return { title: cancelFailedTitle, message: error instanceof Error ? error.message : undefined };
|
|
}
|