Skip to content

Commit cc6826e

Browse files
Add multi-identity support: send/RSVP from multiple addresses per mailbox
Introduce EXTRA_IDENTITIES config for multiple email addresses. Users can filter inbox by identity, compose/reply from the correct address, and RSVP from the invited address. Setup script now creates Email Routing rules for all identities. UI shows identity picker, "To" badges in combined view, and organizer selector for calendar events.
1 parent 04aa8b4 commit cc6826e

14 files changed

Lines changed: 365 additions & 45 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ No Google. No iCloud. No third-party mail service.
2020
| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
2121
| 📥 **Inbound email** | Email Routing → Worker `email()` handler → parsed & stored in D1 |
2222
| 📤 **Outbound email** | Send real mail as `you@yourdomain.com` via the `send_email` binding |
23-
| 📅 **Calendar invites** | Parses `text/calendar`, RSVP **Yes / Maybe / No** sends a real iCal `METHOD:REPLY` — organizers see `you@yourdomain.com: Accepted` |
23+
| � **Multi-identity** | Multiple addresses per mailbox — pick an identity, send as it, RSVP from the invited address |
24+
| �📅 **Calendar invites** | Parses `text/calendar`, RSVP **Yes / Maybe / No** sends a real iCal `METHOD:REPLY` — organizers see `you@yourdomain.com: Accepted` |
2425
| 🗓️ **Independent calendar** | Create/edit/delete events in your own calendar, send `REQUEST`/`CANCEL` invites, import `.ics` anywhere |
2526
| ⏰ **Reminders** | Cron trigger emails you before events |
2627
| 🔐 **Auth** | Password login, HMAC-signed session cookie |

‎public/app.js‎

Lines changed: 94 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ function toast(msg, kind = "ok") {
3434

3535
let state = {
3636
cfg: null,
37+
identity: "all", // "all" or one of cfg.identities[].address
3738
emails: { in: [], out: [] },
3839
events: [],
3940
calCursor: new Date(),
@@ -88,15 +89,37 @@ function switchTab(name) {
8889
}
8990

9091
// ------------------------------------------------------------------ mail list
92+
const parseAddrs = (j) => {
93+
try {
94+
return JSON.parse(j);
95+
} catch {
96+
return [];
97+
}
98+
};
99+
100+
// Which of our identities an inbound email was addressed to (to/cc).
101+
const ourRecipientOf = (m) => {
102+
const ids = state.cfg?.identities || [];
103+
const rcpts = [...parseAddrs(m.to_addresses), ...parseAddrs(m.cc_addresses)];
104+
for (const r of rcpts) {
105+
const hit = ids.find((i) => i.address === String(r).toLowerCase());
106+
if (hit) return hit.address;
107+
}
108+
return "";
109+
};
110+
91111
async function loadBox(box) {
92-
const { emails } = await api(`/api/emails?box=${box}`);
112+
const addr =
113+
state.identity === "all" ? "" : `&address=${encodeURIComponent(state.identity)}`;
114+
const { emails } = await api(`/api/emails?box=${box}${addr}`);
93115
state.emails[box] = emails;
94116
if (box === "in") {
95117
const unread = emails.filter((m) => !m.read).length;
96118
$("#inbox-count").textContent = unread || "";
97119
}
98120
const list = $(box === "in" ? "#inbox-list" : "#sent-list");
99121
list.innerHTML = emails.length ? "" : `<div class="item sub">No messages yet</div>`;
122+
const multi = (state.cfg?.identities?.length || 0) > 1;
100123
for (const m of emails) {
101124
const div = document.createElement("div");
102125
div.className =
@@ -106,8 +129,15 @@ async function loadBox(box) {
106129
const badge = m.has_calendar
107130
? `<span class="badge ${m.calendar_method === "CANCEL" ? "cancel" : m.calendar_method === "REPLY" ? "reply" : ""}">${esc(m.calendar_method || "invite")}</span>`
108131
: "";
132+
// In the combined view, tag which address received/sent each message.
133+
const whoBadge =
134+
multi && state.identity === "all"
135+
? `<span class="badge to">${esc(
136+
box === "in" ? ourRecipientOf(m) : m.from_address,
137+
)}</span>`
138+
: "";
109139
div.innerHTML = `
110-
<div class="from">${esc(box === "in" ? m.from_address : joinAddrs(m.to_addresses))} ${badge}</div>
140+
<div class="from">${esc(box === "in" ? m.from_address : joinAddrs(m.to_addresses))} ${badge} ${whoBadge}</div>
111141
<div class="subj">${esc(m.subject) || "(no subject)"}</div>
112142
<div class="meta"><span>${fmtDate(m.received_at)}</span></div>`;
113143
div.onclick = () => openEmail(m.id, box);
@@ -190,11 +220,19 @@ async function openEmail(id, box) {
190220
}),
191221
);
192222
detail.querySelector("#btn-reply").onclick = () => {
223+
// Reply as the identity that was addressed (inbox) or that sent (sent box).
224+
const from =
225+
box === "in"
226+
? ourRecipientOf(email)
227+
: (state.cfg?.identities || []).find(
228+
(i) => i.address === email.from_address.toLowerCase(),
229+
)?.address || "";
193230
openCompose(
194-
email.from_address,
231+
box === "in" ? email.from_address : joinAddrs(email.to_addresses),
195232
"Re: " + (email.subject || ""),
196233
"",
197234
email.message_id,
235+
from,
198236
);
199237
};
200238
detail.querySelector("#btn-del").onclick = async () => {
@@ -208,7 +246,12 @@ async function openEmail(id, box) {
208246

209247
// ------------------------------------------------------------------ compose
210248
const composeDlg = $("#compose-modal");
211-
function openCompose(to = "", subject = "", body = "", inReplyTo = "") {
249+
function openCompose(to = "", subject = "", body = "", inReplyTo = "", from = "") {
250+
const sel = $("#compose-from");
251+
sel.value =
252+
from ||
253+
(state.identity !== "all" ? state.identity : state.cfg?.primaryAddress) ||
254+
sel.value;
212255
$("#compose-to").value = to;
213256
$("#compose-subject").value = subject;
214257
$("#compose-body").value = body;
@@ -229,6 +272,7 @@ $("#compose-form").addEventListener("submit", async (e) => {
229272
await api("/api/send", {
230273
method: "POST",
231274
body: {
275+
from: $("#compose-from").value,
232276
to: $("#compose-to").value,
233277
subject: $("#compose-subject").value,
234278
text: $("#compose-body").value,
@@ -336,6 +380,14 @@ function openEventModal(ev, date) {
336380
$("#ev-start").value = toLocalInput(start);
337381
$("#ev-end").value = toLocalInput(end);
338382
$("#ev-attendees").value = (ev?.attendees || []).map((a) => a.email).join(", ");
383+
// Organizer is fixed once the event exists.
384+
const orgSel = $("#ev-organizer");
385+
orgSel.disabled = !!ev;
386+
if (!ev)
387+
orgSel.value =
388+
state.identity !== "all" ? state.identity : state.cfg?.primaryAddress;
389+
else if ((state.cfg?.identities || []).some((i) => i.address === ev.organizerEmail))
390+
orgSel.value = ev.organizerEmail;
339391
$("#ev-delete").classList.toggle("hidden", !ev);
340392
$("#ev-ics").classList.toggle("hidden", !ev);
341393
$("#ev-ics").onclick = () => {
@@ -409,7 +461,7 @@ $("#event-form").addEventListener("submit", async (e) => {
409461
} else {
410462
await api("/api/events", {
411463
method: "POST",
412-
body: { ...body, sendInvites: notify },
464+
body: { ...body, organizer: $("#ev-organizer").value, sendInvites: notify },
413465
});
414466
toast("Event created");
415467
}
@@ -424,7 +476,43 @@ $("#event-form").addEventListener("submit", async (e) => {
424476
// ------------------------------------------------------------------ boot
425477
async function boot() {
426478
state.cfg = await api("/api/config");
427-
$("#me-address").textContent = state.cfg.primaryAddress;
479+
const ids = state.cfg.identities?.length
480+
? state.cfg.identities
481+
: [{ address: state.cfg.primaryAddress, name: state.cfg.displayName }];
482+
483+
const idSel = $("#identity-picker");
484+
idSel.innerHTML =
485+
(ids.length > 1 ? `<option value="all">All addresses</option>` : "") +
486+
ids
487+
.map((i) => `<option value="${esc(i.address)}">${esc(i.address)}</option>`)
488+
.join("");
489+
state.identity = ids.length > 1 ? "all" : ids[0].address;
490+
idSel.value = state.identity;
491+
492+
const fromOpts = ids
493+
.map(
494+
(i) =>
495+
`<option value="${esc(i.address)}">${esc(
496+
i.name && i.name !== i.address ? `${i.name} <${i.address}>` : i.address,
497+
)}</option>`,
498+
)
499+
.join("");
500+
$("#compose-from").innerHTML = fromOpts;
501+
$("#ev-organizer").innerHTML = fromOpts;
502+
503+
const showMe = () => {
504+
$("#me-address").textContent =
505+
state.identity === "all" ? `${ids.length} addresses` : state.identity;
506+
};
507+
showMe();
508+
idSel.onchange = () => {
509+
state.identity = idSel.value;
510+
state.selectedId = null;
511+
showMe();
512+
loadBox("in");
513+
loadBox("out");
514+
};
515+
428516
await Promise.all([loadBox("in"), refreshEvents()]);
429517
showApp();
430518
}

‎public/index.html‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ <h1>DevArts Mail</h1>
3939
<div id="app" class="app hidden">
4040
<aside class="sidebar">
4141
<div class="brand"><span class="brand-dot">D</span> DevArts Lab</div>
42+
<select id="identity-picker" class="identity-picker"></select>
4243
<button id="compose-open" class="btn primary block">+ Compose</button>
4344
<nav>
4445
<button data-tab="inbox" class="nav-item active">
@@ -97,6 +98,7 @@ <h2 id="cal-title"></h2>
9798
<h3>New message</h3>
9899
<button type="button" class="x" data-close>&times;</button>
99100
</div>
101+
<select id="compose-from"></select>
100102
<input id="compose-to" placeholder="To — comma separated" required />
101103
<input id="compose-subject" placeholder="Subject" required />
102104
<textarea id="compose-body" placeholder="Write something…" rows="10"></textarea>
@@ -121,6 +123,7 @@ <h3 id="event-modal-title">Event</h3>
121123
<label>End<input id="ev-end" type="datetime-local" required /></label>
122124
</div>
123125
<input id="ev-attendees" placeholder="Invite emails — comma separated" />
126+
<label>Organizer<select id="ev-organizer"></select></label>
124127
<textarea id="ev-description" placeholder="Description" rows="3"></textarea>
125128
<label class="check"
126129
><input id="ev-notify" type="checkbox" checked /> Email invites / updates to

‎public/styles.css‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ body {
4747

4848
/* ---------- buttons / inputs ---------- */
4949
input,
50+
select,
5051
textarea {
5152
font: inherit;
5253
color: var(--text);
@@ -60,6 +61,7 @@ textarea {
6061
box-shadow 0.15s;
6162
}
6263
input:focus,
64+
select:focus,
6365
textarea:focus {
6466
outline: none;
6567
border-color: var(--accent);
@@ -199,6 +201,11 @@ textarea:focus {
199201
place-items: center;
200202
color: #fff;
201203
}
204+
.identity-picker {
205+
font-size: 12px;
206+
padding: 7px 10px;
207+
background: var(--panel-2);
208+
}
202209
.sidebar nav {
203210
display: flex;
204211
flex-direction: column;
@@ -339,6 +346,11 @@ main {
339346
background: rgba(63, 185, 80, 0.14);
340347
color: var(--ok);
341348
}
349+
.badge.to {
350+
background: var(--panel-2);
351+
color: var(--muted);
352+
text-transform: none;
353+
}
342354

343355
/* ---------- detail ---------- */
344356
.detail {
@@ -633,6 +645,7 @@ dialog::backdrop {
633645
}
634646
.sidebar .brand,
635647
.sidebar .me,
648+
.sidebar .identity-picker,
636649
.nav-item .count {
637650
display: none;
638651
}

‎scripts/setup.mjs‎

Lines changed: 35 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
* 2. Applies migrations (remote).
1111
* 3. Pushes secrets from .dev.vars -> `wrangler secret put`.
1212
* 4. Deploys the Worker.
13-
* 5. Configures the Email Routing rule: PRIMARY_ADDRESS -> this Worker.
13+
* 5. Configures Email Routing rules: PRIMARY_ADDRESS + EXTRA_IDENTITIES
14+
* -> this Worker.
1415
*
1516
* Auth (either works):
1617
* - CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID env (or in .dev.vars), or
@@ -70,6 +71,16 @@ const D1_NAME = vars.D1_DATABASE_NAME || "devarts-mail";
7071
const PRIMARY = vars.PRIMARY_ADDRESS;
7172
const DOMAIN = vars.MAIL_DOMAIN;
7273

74+
// All addresses routed to the worker: primary + EXTRA_IDENTITIES
75+
// ("addr=Display Name;addr2=Name2").
76+
const ADDRESSES = [
77+
PRIMARY,
78+
...(vars.EXTRA_IDENTITIES || "")
79+
.split(";")
80+
.map((p) => p.split("=")[0].trim().toLowerCase())
81+
.filter(Boolean),
82+
].filter((a, i, arr) => a && arr.indexOf(a) === i);
83+
7384
const run = (cmd, opts = {}) =>
7485
execSync(cmd, {
7586
cwd: root,
@@ -156,7 +167,7 @@ run(`npx wrangler deploy`);
156167

157168
// --- 5. email routing rule --------------------------------------------------------
158169
if (API_TOKEN) {
159-
console.log(`-> configuring Email Routing for ${PRIMARY}`);
170+
console.log(`-> configuring Email Routing for ${ADDRESSES.join(", ")}`);
160171
const cf = cfRaw;
161172
const zones = await cf("GET", `/zones?name=${DOMAIN}`);
162173
const zoneId = zones?.[0]?.id;
@@ -169,28 +180,33 @@ if (API_TOKEN) {
169180
await cf("PUT", `/zones/${zoneId}/email/routing`, { enabled: true }).catch(() => {});
170181

171182
const rules = await cf("GET", `/zones/${zoneId}/email/routing/rules`);
172-
const existing = rules.find((r) => r.matchers?.[0]?.value === PRIMARY);
173-
if (existing) {
174-
await cf("PUT", `/zones/${zoneId}/email/routing/rules/${existing.tag}`, {
175-
...existing,
176-
enabled: true,
177-
actions: [{ type: "worker", value: [WORKER_NAME] }],
178-
});
179-
console.log(` updated existing rule -> worker "${WORKER_NAME}"`);
180-
} else {
181-
await cf("POST", `/zones/${zoneId}/email/routing/rules`, {
182-
name: `${WORKER_NAME}: ${PRIMARY}`,
183-
enabled: true,
184-
matchers: [{ type: "literal", field: "to", value: PRIMARY }],
185-
actions: [{ type: "worker", value: [WORKER_NAME] }],
186-
});
187-
console.log(` created rule ${PRIMARY} -> worker "${WORKER_NAME}"`);
183+
for (const addr of ADDRESSES) {
184+
const existing = rules.find(
185+
(r) => r.matchers?.[0]?.value?.toLowerCase() === addr.toLowerCase(),
186+
);
187+
if (existing) {
188+
await cf("PUT", `/zones/${zoneId}/email/routing/rules/${existing.tag}`, {
189+
...existing,
190+
enabled: true,
191+
actions: [{ type: "worker", value: [WORKER_NAME] }],
192+
});
193+
console.log(` updated existing rule ${addr} -> worker "${WORKER_NAME}"`);
194+
} else {
195+
await cf("POST", `/zones/${zoneId}/email/routing/rules`, {
196+
name: `${WORKER_NAME}: ${addr}`,
197+
enabled: true,
198+
matchers: [{ type: "literal", field: "to", value: addr }],
199+
actions: [{ type: "worker", value: [WORKER_NAME] }],
200+
});
201+
console.log(` created rule ${addr} -> worker "${WORKER_NAME}"`);
202+
}
188203
}
189204
console.log(" (contact@ rules left untouched)");
190205
} else {
191206
console.log(" ! no API token or wrangler login — skipped email routing setup.");
192207
console.log(` Manually: Dashboard -> ${DOMAIN} -> Email Routing -> Rules ->`);
193-
console.log(` ${PRIMARY} -> Send to Worker -> ${WORKER_NAME}`);
208+
for (const addr of ADDRESSES)
209+
console.log(` ${addr} -> Send to Worker -> ${WORKER_NAME}`);
194210
}
195211

196212
console.log("\nDone. Open https://" + WORKER_NAME + ".<subdomain>.workers.dev");

0 commit comments

Comments
 (0)