Merge branch 'main' into feature/scheduled-send
# Conflicts: # app/(main)/[locale]/page.tsx # components/email/email-list.tsx # components/email/email-viewer.tsx
This commit is contained in:
@@ -75,6 +75,26 @@ export function apiFetch(input: string, init?: RequestInit): Promise<Response> {
|
||||
return fetch(input, init);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount-prefix-aware wrapper for URL strings used in `<img src>`, `<link href>`,
|
||||
* `window.location.*`, etc. — anything the browser resolves itself, where
|
||||
* `apiFetch` can't help.
|
||||
*
|
||||
* Idempotent: passing an already-prefixed value, an external URL, a
|
||||
* protocol-relative URL, or an empty/falsy value returns it unchanged. So it's
|
||||
* safe to wrap admin-configurable values that might be either a local path
|
||||
* (`/branding/foo.svg`, `/api/admin/branding/...`) or a full URL.
|
||||
*/
|
||||
export function withBasePath(url: string | null | undefined): string {
|
||||
if (!url) return url ?? '';
|
||||
if (url.charCodeAt(0) !== 47) return url; // not absolute (e.g. https://, data:, blob:)
|
||||
if (url.charCodeAt(1) === 47) return url; // protocol-relative //cdn...
|
||||
const prefix = getPathPrefix();
|
||||
if (!prefix) return url;
|
||||
if (url === prefix || url.startsWith(prefix + '/')) return url;
|
||||
return prefix + url;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Extracts the locale from the current URL, skipping any mount prefix.
|
||||
|
||||
@@ -283,6 +283,6 @@ export interface IJMAPClient {
|
||||
copyFileNode(id: string, newName: string, parentId: string | null): Promise<FileNode>;
|
||||
|
||||
// ── S/MIME raw-email helpers ──────────────────────────────────
|
||||
importRawEmail(blob: Blob, mailboxIds: Record<string, boolean>, keywords?: Record<string, boolean>): Promise<string>;
|
||||
importRawEmail(blob: Blob, mailboxIds: Record<string, boolean>, keywords?: Record<string, boolean>, accountId?: string): Promise<string>;
|
||||
submitEmail(emailId: string, identityId: string): Promise<void>;
|
||||
}
|
||||
|
||||
+17
-7
@@ -2871,7 +2871,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
}
|
||||
|
||||
async uploadBlob(file: File): Promise<{ blobId: string; size: number; type: string }> {
|
||||
async uploadBlob(file: File, accountId?: string): Promise<{ blobId: string; size: number; type: string }> {
|
||||
if (!this.session) {
|
||||
throw new Error('Not connected. Call connect() first.');
|
||||
}
|
||||
@@ -2881,7 +2881,8 @@ export class JMAPClient implements IJMAPClient {
|
||||
throw new Error('Upload URL not available');
|
||||
}
|
||||
|
||||
const finalUploadUrl = uploadUrl.replace('{accountId}', encodeURIComponent(this.accountId));
|
||||
const targetAccountId = accountId || this.accountId;
|
||||
const finalUploadUrl = uploadUrl.replace('{accountId}', encodeURIComponent(targetAccountId));
|
||||
const response = await this.authenticatedFetch(finalUploadUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': file.type || 'application/octet-stream' },
|
||||
@@ -2911,7 +2912,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
|
||||
// Nested format: { [accountId]: { blobId, type, size } }
|
||||
const blobInfo = result[this.accountId];
|
||||
const blobInfo = result[targetAccountId];
|
||||
if (blobInfo?.blobId) {
|
||||
return {
|
||||
blobId: blobInfo.blobId,
|
||||
@@ -5464,20 +5465,29 @@ export class JMAPClient implements IJMAPClient {
|
||||
return response.arrayBuffer();
|
||||
}
|
||||
|
||||
/** Import a raw MIME message blob into the account. */
|
||||
/**
|
||||
* Import a raw MIME message blob into the account. Pass `accountId` to
|
||||
* target a delegated account the caller has rights on (e.g. importing into
|
||||
* a shared mailbox owned by another user). When omitted, falls back to the
|
||||
* client's own primary account.
|
||||
*/
|
||||
async importRawEmail(
|
||||
blob: Blob,
|
||||
mailboxIds: Record<string, boolean>,
|
||||
keywords?: Record<string, boolean>,
|
||||
accountId?: string,
|
||||
): Promise<string> {
|
||||
// First upload the blob
|
||||
const targetAccountId = accountId || this.accountId;
|
||||
// First upload the blob. Blob uploads are scoped to an account too —
|
||||
// when importing into a delegated account, upload there so the resulting
|
||||
// blobId is visible to Email/import on that account.
|
||||
const file = new File([blob], 'message.eml', { type: 'message/rfc822' });
|
||||
const { blobId } = await this.uploadBlob(file);
|
||||
const { blobId } = await this.uploadBlob(file, targetAccountId);
|
||||
|
||||
// Then import via Email/import
|
||||
const response = await this.request([
|
||||
['Email/import', {
|
||||
accountId: this.accountId,
|
||||
accountId: targetAccountId,
|
||||
emails: {
|
||||
'smime-import': {
|
||||
blobId,
|
||||
|
||||
+32
-4
@@ -6,6 +6,11 @@ export interface UnifiedAccountClient {
|
||||
accountLabel: string;
|
||||
client: IJMAPClient;
|
||||
mailboxes: Mailbox[];
|
||||
// When true, this entry represents a group/shared account owned by
|
||||
// `accountId` but accessed through someone else's `client`. JMAP requests
|
||||
// must use the mailbox's `originalId` and explicitly target this accountId
|
||||
// so the server routes to the owner's data.
|
||||
isShared?: boolean;
|
||||
}
|
||||
|
||||
export interface UnifiedFetchResult {
|
||||
@@ -60,10 +65,11 @@ export async function fetchUnifiedEmails(
|
||||
const mailbox = findMailboxByRole(account.mailboxes, role);
|
||||
if (!mailbox) return null;
|
||||
|
||||
const { jmapMailboxId, jmapAccountId } = resolveJmapTarget(account, mailbox);
|
||||
try {
|
||||
const result = await account.client.getEmails(
|
||||
mailbox.id,
|
||||
undefined,
|
||||
jmapMailboxId,
|
||||
jmapAccountId,
|
||||
limit,
|
||||
position,
|
||||
);
|
||||
@@ -131,7 +137,8 @@ export async function searchUnifiedEmails(
|
||||
position: number,
|
||||
): Promise<UnifiedFetchResult> {
|
||||
return fanOutUnifiedQuery(accounts, role, async (account, mailbox) => {
|
||||
return account.client.searchEmails(query, mailbox.id, undefined, limit, position);
|
||||
const { jmapMailboxId, jmapAccountId } = resolveJmapTarget(account, mailbox);
|
||||
return account.client.searchEmails(query, jmapMailboxId, jmapAccountId, limit, position);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -149,10 +156,31 @@ export async function advancedSearchUnifiedEmails(
|
||||
position: number,
|
||||
): Promise<UnifiedFetchResult> {
|
||||
return fanOutUnifiedQuery(accounts, role, async (account, mailbox) => {
|
||||
return account.client.advancedSearchEmails(filterFor(mailbox.id), undefined, limit, position);
|
||||
const { jmapMailboxId, jmapAccountId } = resolveJmapTarget(account, mailbox);
|
||||
return account.client.advancedSearchEmails(filterFor(jmapMailboxId), jmapAccountId, limit, position);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the JMAP-side mailbox id and accountId for a mailbox living inside
|
||||
* a UnifiedAccountClient. For personal-account entries we use the JMAP id as
|
||||
* returned by the primary client; for shared-owner entries the mailbox id is
|
||||
* namespaced (`${ownerId}:${origId}`) so we must use `originalId` and pass the
|
||||
* owner's accountId through the request.
|
||||
*/
|
||||
function resolveJmapTarget(
|
||||
account: UnifiedAccountClient,
|
||||
mailbox: Mailbox,
|
||||
): { jmapMailboxId: string; jmapAccountId: string | undefined } {
|
||||
if (account.isShared) {
|
||||
return {
|
||||
jmapMailboxId: mailbox.originalId ?? mailbox.id,
|
||||
jmapAccountId: account.accountId,
|
||||
};
|
||||
}
|
||||
return { jmapMailboxId: mailbox.id, jmapAccountId: undefined };
|
||||
}
|
||||
|
||||
async function fanOutUnifiedQuery(
|
||||
accounts: UnifiedAccountClient[],
|
||||
role: UnifiedMailboxRole,
|
||||
|
||||
+76
-12
@@ -3,6 +3,8 @@ import { twMerge } from "tailwind-merge";
|
||||
import { Mailbox, UNIFIED_MAILBOX_IDS } from "./jmap/types";
|
||||
import type { UnifiedMailboxRole } from "./jmap/types";
|
||||
import { debug } from "./debug";
|
||||
import { useLocaleStore } from "@/stores/locale-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
@@ -40,24 +42,86 @@ export function generateUUID(): string {
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a received-at date for the email list. The output style is
|
||||
* controlled by the `dateFormat` user setting:
|
||||
*
|
||||
* - `smart` (default) — locale-aware, age-bucketed:
|
||||
* today → time only ("15:31" or "3:31 PM")
|
||||
* last 7 days → short weekday+time ("Fr 15:31", "Fri 3:31 PM")
|
||||
* older → full locale date ("28.04.2026", "04/28/2026")
|
||||
* - `relative` — legacy en-US relative format ("1h ago", "2d ago").
|
||||
* - `full` — always the full locale date+time.
|
||||
*
|
||||
* Both the locale (from the language picker) and 12h/24h preference are
|
||||
* read via `getState()` so this stays SSR-safe.
|
||||
*/
|
||||
export function formatDate(date: Date | string): string {
|
||||
const d = typeof date === "string" ? new Date(date) : date;
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - d.getTime();
|
||||
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(diff / 86400000);
|
||||
const localeRaw = useLocaleStore.getState().locale;
|
||||
const locale = localeRaw && localeRaw.length > 0 ? localeRaw : "en";
|
||||
// `en` alone resolves to en-US in Intl; everything else uses the language
|
||||
// subtag as-is and lets the runtime pick a sensible default region.
|
||||
const intlLocale = locale === "en" ? "en-US" : locale;
|
||||
const { dateFormat, timeFormat } = useSettingsStore.getState();
|
||||
const hour12 = timeFormat === "12h";
|
||||
|
||||
if (minutes < 1) return "Just now";
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
if (days < 7) return `${days}d ago`;
|
||||
if (dateFormat === "relative") {
|
||||
const diff = now.getTime() - d.getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(diff / 86400000);
|
||||
if (minutes < 1) return "Just now";
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
if (days < 7) return `${days}d ago`;
|
||||
return d.toLocaleDateString(intlLocale, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: d.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return d.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: d.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
|
||||
if (dateFormat === "full") {
|
||||
return d.toLocaleString(intlLocale, {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12,
|
||||
});
|
||||
}
|
||||
|
||||
// 'smart' (default)
|
||||
const timeStr = d.toLocaleTimeString(intlLocale, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12,
|
||||
});
|
||||
|
||||
const isSameDay =
|
||||
d.getFullYear() === now.getFullYear() &&
|
||||
d.getMonth() === now.getMonth() &&
|
||||
d.getDate() === now.getDate();
|
||||
if (isSameDay) return timeStr;
|
||||
|
||||
const daysAgo = Math.floor((now.getTime() - d.getTime()) / 86400000);
|
||||
if (daysAgo < 7) {
|
||||
// German Intl outputs "Fr." with a trailing dot for `weekday: 'short'`;
|
||||
// strip it so the result reads cleanly next to the time.
|
||||
const weekday = d
|
||||
.toLocaleDateString(intlLocale, { weekday: "short" })
|
||||
.replace(/\.$/, "");
|
||||
return `${weekday} ${timeStr}`;
|
||||
}
|
||||
|
||||
return d.toLocaleDateString(intlLocale, {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
|
||||
import { apiFetch, withBasePath } from '@/lib/browser-navigation';
|
||||
|
||||
export interface WebDAVResource {
|
||||
href: string;
|
||||
@@ -32,7 +33,7 @@ export class WebDAVClient {
|
||||
...options?.headers,
|
||||
};
|
||||
|
||||
return fetch(this.proxyUrl, {
|
||||
return apiFetch(this.proxyUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: options?.body,
|
||||
@@ -118,7 +119,7 @@ export class WebDAVClient {
|
||||
// Use XMLHttpRequest for progress tracking
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', this.proxyUrl);
|
||||
xhr.open('POST', withBasePath(this.proxyUrl));
|
||||
xhr.setRequestHeader('X-WebDAV-Method', 'PUT');
|
||||
xhr.setRequestHeader('X-WebDAV-Path', path);
|
||||
const slotHeaders = getActiveAccountSlotHeaders();
|
||||
|
||||
Reference in New Issue
Block a user