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;
|
user: string | null;
|
||||||
imapHost: string | null;
|
imapHost: string | null;
|
||||||
allowInsecureTls: boolean;
|
allowInsecureTls: boolean;
|
||||||
|
fromAddress: string | null;
|
||||||
verifiedAt: number | null;
|
verifiedAt: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ export function MailboxSettingsForm({ initialStatus }: { initialStatus: Status }
|
|||||||
const [smtpPort, setSmtpPort] = useState("587");
|
const [smtpPort, setSmtpPort] = useState("587");
|
||||||
const [user, setUser] = useState(initialStatus.user ?? "");
|
const [user, setUser] = useState(initialStatus.user ?? "");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
const [fromAddress, setFromAddress] = useState(initialStatus.fromAddress ?? "");
|
||||||
const [allowInsecureTls, setAllowInsecureTls] = useState(true);
|
const [allowInsecureTls, setAllowInsecureTls] = useState(true);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -38,6 +40,7 @@ export function MailboxSettingsForm({ initialStatus }: { initialStatus: Status }
|
|||||||
user,
|
user,
|
||||||
password,
|
password,
|
||||||
allowInsecureTls,
|
allowInsecureTls,
|
||||||
|
fromAddress,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -48,7 +51,15 @@ export function MailboxSettingsForm({ initialStatus }: { initialStatus: Status }
|
|||||||
return;
|
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("");
|
setPassword("");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,6 +142,21 @@ export function MailboxSettingsForm({ initialStatus }: { initialStatus: Status }
|
|||||||
/>
|
/>
|
||||||
</label>
|
</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">
|
<label className="mb-4 flex items-center gap-2 text-sm text-text-muted">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const configureSchema = z.object({
|
|||||||
user: z.string().email(),
|
user: z.string().email(),
|
||||||
password: z.string().min(1),
|
password: z.string().min(1),
|
||||||
allowInsecureTls: z.boolean(),
|
allowInsecureTls: z.boolean(),
|
||||||
|
fromAddress: z.string().email().optional().or(z.literal("")),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
@@ -41,6 +42,7 @@ export async function POST(request: Request) {
|
|||||||
user: parsed.data.user,
|
user: parsed.data.user,
|
||||||
password: parsed.data.password,
|
password: parsed.data.password,
|
||||||
allowInsecureTls: parsed.data.allowInsecureTls,
|
allowInsecureTls: parsed.data.allowInsecureTls,
|
||||||
|
fromAddress: parsed.data.fromAddress || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
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,
|
"when": 1791361267938,
|
||||||
"tag": "0012_flawless_lord_tyger",
|
"tag": "0012_flawless_lord_tyger",
|
||||||
"breakpoints": true
|
"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),
|
smtpPort: integer("smtp_port").notNull().default(587),
|
||||||
user: text("user").notNull(),
|
user: text("user").notNull(),
|
||||||
passwordEnc: text("password_enc").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
|
// 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),
|
// setup time. This is a LAN-only mailbox (never exposed to the internet),
|
||||||
// so skipping verification is an accepted risk — not a default for
|
// so skipping verification is an accepted risk — not a default for
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ export interface MailboxSettings {
|
|||||||
user: string;
|
user: string;
|
||||||
password: string;
|
password: string;
|
||||||
allowInsecureTls: boolean;
|
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. */
|
/** 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,
|
user: config.user,
|
||||||
password: decryptCredential(config.passwordEnc),
|
password: decryptCredential(config.passwordEnc),
|
||||||
allowInsecureTls: config.allowInsecureTls,
|
allowInsecureTls: config.allowInsecureTls,
|
||||||
|
fromAddress: config.fromAddress ?? undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +42,7 @@ export async function saveMailboxSettings(settings: MailboxSettings): Promise<vo
|
|||||||
user: settings.user,
|
user: settings.user,
|
||||||
passwordEnc: encryptCredential(settings.password),
|
passwordEnc: encryptCredential(settings.password),
|
||||||
allowInsecureTls: settings.allowInsecureTls,
|
allowInsecureTls: settings.allowInsecureTls,
|
||||||
|
fromAddress: settings.fromAddress || null,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
verifiedAt: new Date(),
|
verifiedAt: new Date(),
|
||||||
};
|
};
|
||||||
@@ -65,6 +70,7 @@ export async function getMailboxStatus() {
|
|||||||
user: config?.user ?? null,
|
user: config?.user ?? null,
|
||||||
imapHost: config?.imapHost ?? null,
|
imapHost: config?.imapHost ?? null,
|
||||||
allowInsecureTls: config?.allowInsecureTls ?? false,
|
allowInsecureTls: config?.allowInsecureTls ?? false,
|
||||||
|
fromAddress: config?.fromAddress ?? null,
|
||||||
verifiedAt: config?.verifiedAt?.getTime() ?? null,
|
verifiedAt: config?.verifiedAt?.getTime() ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -32,11 +32,12 @@ export async function sendTicketReplyEmail(params: {
|
|||||||
const settings = await getMailboxSettings();
|
const settings = await getMailboxSettings();
|
||||||
if (!settings) throw new Error("Email channel is not configured");
|
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}`;
|
const subject = params.subject.toLowerCase().startsWith("re:") ? params.subject : `Re: ${params.subject}`;
|
||||||
|
|
||||||
await createTransport(settings).sendMail({
|
await createTransport(settings).sendMail({
|
||||||
from: settings.user,
|
from: fromAddress,
|
||||||
to: params.to,
|
to: params.to,
|
||||||
subject,
|
subject,
|
||||||
text: params.body,
|
text: params.body,
|
||||||
|
|||||||
Reference in new issue
Block a user