Add a separate "From" address for mailboxes that can't send as their own login

Real error from the previously-silent delivery failure: "550 5.7.60 SMTP;
Client does not have permissions to send as this sender" — Exchange
Online's exact rejection when the authenticated identity doesn't match
the asserted From address. Common in hybrid M365/on-prem AD setups: the
SMTP login is the AD UPN (e.g. elsup@zag.lan), but the mailbox's actual
permitted send-as address is a different, customer-facing domain.

Added an optional mailbox_config.fromAddress, surfaced in the email
settings form, defaulting to the login when unset so every existing setup
is unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gh2UXUQUVBWroWEnn1FLFG
This commit is contained in:
ogrechkoandClaude Sonnet 5 committed 2026-10-07 10:02:24 +00:00
1 parent b92d9801ff
commit c4e48328ee
8 files changed
+1269 -3

No files matched your search

@@ -9,6 +9,7 @@ interface Status {
user: string | null;
imapHost: string | null;
allowInsecureTls: boolean;
fromAddress: string | null;
verifiedAt: number | null;
}
@@ -19,6 +20,7 @@ export function MailboxSettingsForm({ initialStatus }: { initialStatus: Status }
const [smtpPort, setSmtpPort] = useState("587");
const [user, setUser] = useState(initialStatus.user ?? "");
const [password, setPassword] = useState("");
const [fromAddress, setFromAddress] = useState(initialStatus.fromAddress ?? "");
const [allowInsecureTls, setAllowInsecureTls] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -38,6 +40,7 @@ export function MailboxSettingsForm({ initialStatus }: { initialStatus: Status }
user,
password,
allowInsecureTls,
fromAddress,
}),
});
@@ -48,7 +51,15 @@ export function MailboxSettingsForm({ initialStatus }: { initialStatus: Status }
return;
}
setStatus({ configured: true, enabled: true, user, imapHost: host, allowInsecureTls, verifiedAt: Date.now() });
setStatus({
configured: true,
enabled: true,
user,
imapHost: host,
allowInsecureTls,
fromAddress: fromAddress || null,
verifiedAt: Date.now(),
});
setPassword("");
}
@@ -131,6 +142,21 @@ export function MailboxSettingsForm({ initialStatus }: { initialStatus: Status }
/>
</label>
<label className="mb-3 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Адрес отправителя (если отличается от логина)</span>
<input
type="email"
value={fromAddress}
onChange={(e) => setFromAddress(e.target.value)}
placeholder="support@zaglushka.ru"
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
<span className="mt-1 block text-xs text-text-faint">
Нужно, если логин — служебная учётка (напр. AD UPN), а письма клиенту должны уходить от другого адреса.
Оставьте пустым, если логин и есть нужный адрес.
</span>
</label>
<label className="mb-4 flex items-center gap-2 text-sm text-text-muted">
<input
type="checkbox"
+2
View File
@@ -21,6 +21,7 @@ const configureSchema = z.object({
user: z.string().email(),
password: z.string().min(1),
allowInsecureTls: z.boolean(),
fromAddress: z.string().email().optional().or(z.literal("")),
});
export async function POST(request: Request) {
@@ -41,6 +42,7 @@ export async function POST(request: Request) {
user: parsed.data.user,
password: parsed.data.password,
allowInsecureTls: parsed.data.allowInsecureTls,
fromAddress: parsed.data.fromAddress || undefined,
};
try {
@@ -0,0 +1 @@
ALTER TABLE `mailbox_config` ADD `from_address` text;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -92,6 +92,13 @@
"when": 1791361267938,
"tag": "0012_flawless_lord_tyger",
"breakpoints": true
},
{
"idx": 13,
"version": "6",
"when": 1791367080545,
"tag": "0013_military_ezekiel_stane",
"breakpoints": true
}
]
}
+7
View File
@@ -174,6 +174,13 @@ export const mailboxConfig = sqliteTable("mailbox_config", {
smtpPort: integer("smtp_port").notNull().default(587),
user: text("user").notNull(),
passwordEnc: text("password_enc").notNull(),
// Null unless the SMTP AUTH identity can't send as itself — e.g. an
// on-prem/hybrid M365 setup where `user` is the AD UPN used to log in
// (like elsup@zag.lan) but the mailbox's actual permitted "From" address
// is a different, customer-facing domain. Exchange Online rejects a
// mismatched From with "550 5.7.60 ... does not have permissions to send
// as this sender" — see sendTicketReplyEmail. Defaults to `user` when unset.
fromAddress: text("from_address"),
// Both IMAP and SMTP presented an expired cert on support@top-sysops.ru at
// setup time. This is a LAN-only mailbox (never exposed to the internet),
// so skipping verification is an accepted risk — not a default for
+6
View File
@@ -11,6 +11,9 @@ export interface MailboxSettings {
user: string;
password: string;
allowInsecureTls: boolean;
// See the schema comment on mailboxConfig.fromAddress — undefined means
// "same as user", the common case.
fromAddress?: string;
}
/** Settings for the running listener/sender — null if never configured or disabled. */
@@ -26,6 +29,7 @@ export async function getMailboxSettings(): Promise<MailboxSettings | null> {
user: config.user,
password: decryptCredential(config.passwordEnc),
allowInsecureTls: config.allowInsecureTls,
fromAddress: config.fromAddress ?? undefined,
};
}
@@ -38,6 +42,7 @@ export async function saveMailboxSettings(settings: MailboxSettings): Promise<vo
user: settings.user,
passwordEnc: encryptCredential(settings.password),
allowInsecureTls: settings.allowInsecureTls,
fromAddress: settings.fromAddress || null,
enabled: true,
verifiedAt: new Date(),
};
@@ -65,6 +70,7 @@ export async function getMailboxStatus() {
user: config?.user ?? null,
imapHost: config?.imapHost ?? null,
allowInsecureTls: config?.allowInsecureTls ?? false,
fromAddress: config?.fromAddress ?? null,
verifiedAt: config?.verifiedAt?.getTime() ?? null,
};
}
+3 -2
View File
@@ -32,11 +32,12 @@ export async function sendTicketReplyEmail(params: {
const settings = await getMailboxSettings();
if (!settings) throw new Error("Email channel is not configured");
const messageId = `<${crypto.randomUUID()}@${settings.user.split("@")[1] ?? "top-sysops.ru"}>`;
const fromAddress = settings.fromAddress || settings.user;
const messageId = `<${crypto.randomUUID()}@${fromAddress.split("@")[1] ?? "top-sysops.ru"}>`;
const subject = params.subject.toLowerCase().startsWith("re:") ? params.subject : `Re: ${params.subject}`;
await createTransport(settings).sendMail({
from: settings.user,
from: fromAddress,
to: params.to,
subject,
text: params.body,