toeverything/AFFiNE · error · EmailServiceNotConfigured

email_service_not_configured

email_service_not_configured

Error message

Email service is not configured.

What it means

Mailer.send throws EmailServiceNotConfigured (internal_server_error / email_service_not_configured) when MailSender.configured is false — meaning no SMTP transport was ever created because the mailer SMTP config (MAILER_HOST/PORT/USER/PASSWORD/SENDER, set up on the config.init event) is absent and the environment is not a test env. trySend()/send(command, true) suppress this and return false instead.

Solutions

  1. Configure SMTP: set the MAILER_* env vars (host, port, user, password, sender) or the equivalent app-config mailer.SMTP block and restart
  2. Use mailer.trySend(command) for optional mail so unconfigured SMTP degrades to false instead of throwing
  3. Catch EmailServiceNotConfigured in flows that must continue without email and surface a 'mail not configured' state to the operator
  4. In dev, use an Ethereal/test SMTP account or set env.testing to use the mock transport

Example fix

// before
await mailer.send(inviteMail);

// after
const ok = await mailer.trySend(inviteMail); // false instead of throwing when SMTP unset
if (!ok) logger.warn('mail skipped: SMTP not configured');
Defensive patterns

Strategy: fallback

Validate before calling

// only attempt send when mail is actually configured
if (!mailerConfigured()) {  // e.g. !!process.env.MAILER_HOST && !env.testing
  logger.warn('SMTP not configured; skipping mail');
  return false;
}
await mailer.send(command);

Type guard

function isEmailServiceNotConfigured(e: unknown): boolean {
  return (e as { extensions?: { code?: string } }).extensions?.code === 'email_service_not_configured';
}

Try / catch

try {
  await mailer.send(command);
} catch (e) {
  if (isEmailServiceNotConfigured(e)) {
    return false; // degrade gracefully; surface 'configure SMTP' to the operator
  }
  throw e;
}

Prevention

When it happens

Trigger: Calling mailer.send directly (or a code path that requires email, like verification mails) on a self-hosted deployment with no MAILER_* configuration; sending before the config.init event has run; overriding mail config with empty values.

Common situations: Fresh self-hosted installs that skipped the email setup step in the environment file; staging environments cloned without the mailer env block; features that call send() (throwing variant) instead of trySend() for optional notifications.

Related errors


AI-assisted analysis of toeverything/AFFiNE@2af30773ae (2026-08-18). Data as JSON: /api/errors/4f01902a50ae3137. Report an issue: GitHub.

Appendix: source

Thrown at packages/backend/server/src/core/mail/mailer.ts:91

      recipientUserId: metadata.recipientUserId,
      actorUserId: metadata.actorUserId,
      workspaceId: metadata.workspaceId,
      notificationId: metadata.notificationId,
      abuseSubjectKey: metadata.abuseSubjectKey,
      payload: serializePayload(command),
      expiresAt: metadata.expiresAt,
      maxAttempts: 0,
      lastErrorCode: options.reason,
    });
    return false;
  }

  async send(command: MailCommand, suppressError = false) {
    if (!this.sender.configured) {
      if (suppressError) {
        return false;
      }
      throw new EmailServiceNotConfigured();
    }

    let reservationId: string | undefined;
    let deliveryId: string | undefined;
    try {
      const metadata = command.metadata ?? {};
      const deduped = metadata.dedupeKey
        ? await this.models.mailDelivery.findByDedupeKey(metadata.dedupeKey)
        : null;
      if (deduped) {
        return !['failed', 'canceled', 'skipped'].includes(deduped.status);
      }

      const decision = await this.runtime.assertMailDeliveryQuotaV1({
        mailName: command.name as MailName,
        recipient: {
          email: command.to,
          userId: metadata.recipientUserId,

View on GitHub (pinned to 2af30773ae)