Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 45 additions & 29 deletions apps/api/src/modules/mail/admin/test-email.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,46 +273,62 @@ export async function sendTestEmail(
// then connects to 127.0.1.1 inside its OWN namespace, where nothing
// listens, and every send fails with `ECONNREFUSED 127.0.1.1:465` while
// Postfix is healthy on 0.0.0.0:465. `servername` keeps TLS validating
// against the hostname, so certificate checking is unchanged.
const connectHost = await resolveSubmissionAddress(smtpHost);
const transporter: Transporter = nodemailer.createTransport({
let transporter: Transporter = nodemailer.createTransport({
host: connectHost,
tls: { servername: smtpHost },
port: SUBMISSION_PORT,
secure: true,
auth: { user: authUser, pass: smtpPassword },
// Timeouts kept short - the dashboard awaits this synchronously and
// the operator is staring at a "Send test" spinner. If the mail VPS
// takes longer than 15 s for AUTH, something is wrong and we want
// the surface error, not the hang.
connectionTimeout: 15_000,
greetingTimeout: 10_000,
socketTimeout: 20_000,
connectionTimeout: 8_000,
greetingTimeout: 8_000,
socketTimeout: 10_000,
});

let verified = false;
try {
await transporter.verify();
verified = true;
} catch (err) {
// With the platform-mailbox primitive owning both ends of the
// credential, a 535 here should be REALLY rare — it implies the
// doveadm hash in `vmail.mailbox` and the plaintext in
// `state.platformMailbox` got out of sync via some path that bypassed
// ensureOpenshipPlatformMailbox (manual psql update, restored state
// file from a different generation, etc.). Tell operators how to
// realign both ends in a single call.
const message = safeErrorMessage(err);
const looksLikeAuthFailure =
/\b535\b/.test(message) ||
/5\.7\.8/.test(message) ||
/authentication\s+failed/i.test(message) ||
/invalid\s+credentials/i.test(message);
const suffix = looksLikeAuthFailure
? ` - the platform mailbox credential and the Dovecot hash appear to have drifted. Click "Rotate platform mailbox password" in the Mail admin panel (calls ensureOpenshipPlatformMailbox with { rotate: true }) to refresh both ends atomically, then retry.`
: ``;
throw wrapSmtpError(
err,
`SMTP submission check failed against ${smtpHost}:${SUBMISSION_PORT}${suffix}`,
);
if (connectHost !== "127.0.0.1") {
const fallbackTransporter: Transporter = nodemailer.createTransport({
host: "127.0.0.1",
tls: { servername: smtpHost },
port: SUBMISSION_PORT,
secure: true,
auth: { user: authUser, pass: smtpPassword },
connectionTimeout: 8_000,
greetingTimeout: 8_000,
socketTimeout: 10_000,
});
try {
await fallbackTransporter.verify();
transporter = fallbackTransporter;
verified = true;
} catch {
// Fallback also failed
}
}

if (!verified) {
const message = safeErrorMessage(err);
const looksLikeAuthFailure =
/\b535\b/.test(message) ||
/5\.7\.8/.test(message) ||
/authentication\s+failed/i.test(message) ||
/invalid\s+credentials/i.test(message);

if (looksLikeAuthFailure) {
throw new TestEmailError(
`Authentication failed for ${authUser} on ${connectHost}:${SUBMISSION_PORT} (${message}). Run /mail/admin/${serverId}/platform-mailbox/rotate to resync the credentials.`,
);
}

throw wrapSmtpError(
err,
`SMTP submission check failed against ${smtpHost}:${SUBMISSION_PORT}`,
);
}
}

let info: { messageId: string; response: string };
Expand Down
4 changes: 2 additions & 2 deletions apps/api/src/modules/mail/webmail/webmail-project.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -714,9 +714,9 @@ export async function startWebmailDeploy(
TRUSTED_ORIGINS: publicOrigin,
SESSION_ENCRYPTION_KEY: sessionEncryptionKey,
BRANDING_ADMIN_TOKEN: brandingToken,
DEFAULT_IMAP_HOST: mailHost,
DEFAULT_IMAP_HOST: input.target.kind === "self" ? "127.0.0.1" : mailHost,
DEFAULT_IMAP_PORT: "993",
DEFAULT_SMTP_HOST: mailHost,
DEFAULT_SMTP_HOST: input.target.kind === "self" ? "127.0.0.1" : mailHost,
DEFAULT_SMTP_PORT: "465",
ACME_EMAIL: deriveAcmeEmail(input.hostname),
};
Expand Down
5 changes: 3 additions & 2 deletions apps/email/client/app/(routes)/mail/[folder]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,11 @@ const ALLOWED_FOLDERS = new Set([
]);

export async function clientLoader({ params, request }: Route.ClientLoaderArgs) {
if (!params.folder) return Response.redirect(`${import.meta.env.VITE_PUBLIC_APP_URL}/mail/inbox`);
const baseUrl = import.meta.env.VITE_PUBLIC_APP_URL || new URL(request.url).origin;
if (!params.folder) return Response.redirect(`${baseUrl}/mail/inbox`);

const session = await authProxy.api.getSession({ headers: request.headers });
if (!session) return Response.redirect(`${import.meta.env.VITE_PUBLIC_APP_URL}/login`);
if (!session) return Response.redirect(`${baseUrl}/login`);

return {
folder: params.folder,
Expand Down
5 changes: 3 additions & 2 deletions apps/email/client/app/(routes)/mail/compose/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,13 @@ import { useLoaderData } from 'react-router';
import type { Route } from './+types/page';

export async function clientLoader({ request }: Route.ClientLoaderArgs) {
const baseUrl = import.meta.env.VITE_PUBLIC_APP_URL || new URL(request.url).origin;
const session = await authProxy.api.getSession({ headers: request.headers });
if (!session) return Response.redirect(`${import.meta.env.VITE_PUBLIC_APP_URL}/login`);
if (!session) return Response.redirect(`${baseUrl}/login`);
const url = new URL(request.url);
if (url.searchParams.get('to')?.startsWith('mailto:')) {
return Response.redirect(
`${import.meta.env.VITE_PUBLIC_APP_URL}/mail/compose/handle-mailto?mailto=${encodeURIComponent(url.searchParams.get('to') ?? '')}`,
`${baseUrl}/mail/compose/handle-mailto?mailto=${encodeURIComponent(url.searchParams.get('to') ?? '')}`,
);
}

Expand Down
5 changes: 3 additions & 2 deletions apps/email/client/app/(routes)/mail/create/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import { authProxy } from '@/lib/auth-proxy';
import type { Route } from './+types/page';

export async function clientLoader({ request }: Route.ClientLoaderArgs) {
const baseUrl = import.meta.env.VITE_PUBLIC_APP_URL || new URL(request.url).origin;
const session = await authProxy.api.getSession({ headers: request.headers });
if (!session) return Response.redirect(`${import.meta.env.VITE_PUBLIC_APP_URL}/login`);
if (!session) return Response.redirect(`${baseUrl}/login`);

const url = new URL(request.url);
const params = Object.fromEntries(url.searchParams.entries()) as {
Expand All @@ -13,7 +14,7 @@ export async function clientLoader({ request }: Route.ClientLoaderArgs) {
};
const toParam = params.to || 'someone@someone.com';
return Response.redirect(
`${import.meta.env.VITE_PUBLIC_APP_URL}/mail/inbox?isComposeOpen=true&to=${encodeURIComponent(toParam)}${params.subject ? `&subject=${encodeURIComponent(params.subject)}` : ''}`,
`${baseUrl}/mail/inbox?isComposeOpen=true&to=${encodeURIComponent(toParam)}${params.subject ? `&subject=${encodeURIComponent(params.subject)}` : ''}`,
);
}

Expand Down
5 changes: 3 additions & 2 deletions apps/email/client/app/(routes)/mail/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export function clientLoader() {
return Response.redirect(`${import.meta.env.VITE_PUBLIC_APP_URL}/mail/inbox`);
export function clientLoader({ request }: { request?: Request } = {}) {
const baseUrl = import.meta.env.VITE_PUBLIC_APP_URL || (request ? new URL(request.url).origin : '');
return Response.redirect(`${baseUrl}/mail/inbox`);
}
3 changes: 2 additions & 1 deletion apps/email/client/app/(routes)/settings/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ import { authProxy } from '@/lib/auth-proxy';
import type { Route } from './+types/layout';

export async function clientLoader({ request }: Route.ClientLoaderArgs) {
const baseUrl = import.meta.env.VITE_PUBLIC_APP_URL || new URL(request.url).origin;
const session = await authProxy.api.getSession({ headers: request.headers });

if (!session) {
return Response.redirect(`${import.meta.env.VITE_PUBLIC_APP_URL}/login`);
return Response.redirect(`${baseUrl}/login`);
}


Expand Down
11 changes: 6 additions & 5 deletions apps/email/client/app/mailto-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,28 +247,29 @@ async function createDraftFromMailto(mailtoData: {
}

export async function clientLoader({ request }: Route.ClientLoaderArgs) {
const baseUrl = import.meta.env.VITE_PUBLIC_APP_URL || new URL(request.url).origin;
const session = await authProxy.api.getSession({ headers: request.headers });
if (!session) return Response.redirect(`${import.meta.env.VITE_PUBLIC_APP_URL}/login`);
if (!session) return Response.redirect(`${baseUrl}/login`);

const url = new URL(request.url);

// Get the mailto parameter from the URL
const mailto = url.searchParams.get('mailto');

if (!mailto) return Response.redirect(`${import.meta.env.VITE_PUBLIC_APP_URL}/mail/compose`);
if (!mailto) return Response.redirect(`${baseUrl}/mail/compose`);

// Parse the mailto URL
const mailtoData = await parseMailtoUrl(mailto);

// If parsing failed, redirect to empty compose
if (!mailtoData) return Response.redirect(`${import.meta.env.VITE_PUBLIC_APP_URL}/mail/compose`);
if (!mailtoData) return Response.redirect(`${baseUrl}/mail/compose`);

// Create a draft from the mailto data
const draftId = await createDraftFromMailto(mailtoData);

// If draft creation failed, redirect to empty compose with the parsed data as a fallback
if (!draftId) {
const fallbackUrl = new URL(`${import.meta.env.VITE_PUBLIC_APP_URL}/mail/compose`);
const fallbackUrl = new URL(`${baseUrl}/mail/compose`);
if (mailtoData.to) fallbackUrl.searchParams.append('to', mailtoData.to);
if (mailtoData.subject) fallbackUrl.searchParams.append('subject', mailtoData.subject);
if (mailtoData.body) fallbackUrl.searchParams.append('body', mailtoData.body);
Expand All @@ -279,6 +280,6 @@ export async function clientLoader({ request }: Route.ClientLoaderArgs) {

// Redirect to compose with the draft ID
return Response.redirect(
`${import.meta.env.VITE_PUBLIC_APP_URL}/mail/compose?draftId=${draftId}`,
`${baseUrl}/mail/compose?draftId=${draftId}`,
);
}