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:
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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user