Merge branch 'refactor/mailbox-core'
This commit is contained in:
commit
d82cf71f5b
20 files changed
+2217
-829
No files matched your search
@@ -0,0 +1,57 @@
|
|||||||
|
# Mailbox core
|
||||||
|
|
||||||
|
The webmail, IMAP, POP3 and SMTP are four ways in to one mailbox. They share
|
||||||
|
the rules and the data code; none of them has its own copy.
|
||||||
|
|
||||||
|
## Where each rule lives
|
||||||
|
|
||||||
|
| Rule | One place |
|
||||||
|
|------|-----------|
|
||||||
|
| Flags, expunge, move, copy, folder delete, counters, quota | `MailStore` (`services/protocols/internal/infrastructure/postgres/mail_store.go`) |
|
||||||
|
| Folder and message reads | `mail_store_read.go` (same store) |
|
||||||
|
| Credentials (password, app password, scopes) | `usecase/credentials.go` |
|
||||||
|
| Sending, and the copy filed in Sent | `ProcessOutboundEmail.Submit` (`FileSentCopy`) |
|
||||||
|
| Default folders of a mailbox | SQL function `provision_default_folders` (migration 0014) |
|
||||||
|
|
||||||
|
The IMAP repositories, the webmail repository and the TS auth service are thin
|
||||||
|
adapters: they translate their protocol and call the code above.
|
||||||
|
|
||||||
|
## Invariants
|
||||||
|
|
||||||
|
After any operation, through any port:
|
||||||
|
|
||||||
|
- `folders.total_count` and `unread_count` equal the live messages and the ones
|
||||||
|
without `\Seen`.
|
||||||
|
- `folders.highest_modseq` rises only when a message really changed.
|
||||||
|
- `mailboxes.used_bytes` equals the size of the live messages.
|
||||||
|
- Deleting twice, or from two ports at once, counts once.
|
||||||
|
|
||||||
|
`mail_state_contract_test.go` checks all of this after every operation through
|
||||||
|
the IMAP path and the webmail path, and under concurrency.
|
||||||
|
`e2e/one_mailbox_everywhere_e2e_test.go` does the same over real SMTP, IMAP and
|
||||||
|
HTTP on one mailbox.
|
||||||
|
|
||||||
|
## Lock order
|
||||||
|
|
||||||
|
Every operation locks **folders first, lowest id first, then the message**
|
||||||
|
(`lockFolders`). Two moves in opposite directions, or a move racing a flag
|
||||||
|
change, used to deadlock (SQLSTATE 40P01) because they locked in different
|
||||||
|
orders. `TestContract_OppositeMovesAndFlagChangesNeverDeadlock` covers it. A new
|
||||||
|
operation must follow the same order.
|
||||||
|
|
||||||
|
## Adding an operation
|
||||||
|
|
||||||
|
1. Put it in `MailStore`, in one transaction, with `lockFolders`.
|
||||||
|
2. Make the adapters call it.
|
||||||
|
3. Add it to the contract test and run it through both paths.
|
||||||
|
|
||||||
|
## Migration 0014
|
||||||
|
|
||||||
|
Creates `provision_default_folders`, creates the missing default folders of
|
||||||
|
existing mailboxes, and recomputes folder counters and `used_bytes` from the
|
||||||
|
messages. It is idempotent and runs with the other migrations at deploy.
|
||||||
|
|
||||||
|
## Not done
|
||||||
|
|
||||||
|
IMAP `APPEND` is still a stub. When it is implemented, drop a client-appended
|
||||||
|
Sent copy that matches the server's one by `Message-ID`.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- The reconciliation corrected cached numbers and cannot be undone (and has no
|
||||||
|
-- reason to be); only the function is removed. Folders it added stay: they are
|
||||||
|
-- ordinary folders by now and may hold mail.
|
||||||
|
DROP FUNCTION IF EXISTS provision_default_folders(UUID);
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
-- One definition of "a mailbox's standard folders", and a reconciliation of the
|
||||||
|
-- cached numbers that two code paths used to keep on their own.
|
||||||
|
--
|
||||||
|
-- 1. provision_default_folders
|
||||||
|
--
|
||||||
|
-- The standard folders were created in two places that had already diverged:
|
||||||
|
-- the console's createMailbox added Reports, the CLI that creates the first
|
||||||
|
-- administrator did not, so that account had no folder for DMARC and TLS-RPT
|
||||||
|
-- reports and they fell into its INBOX. Both now call this function, so the
|
||||||
|
-- list lives here and nowhere else. It is idempotent: it adds only what is
|
||||||
|
-- missing, matching by name without regard to case.
|
||||||
|
CREATE OR REPLACE FUNCTION provision_default_folders(p_mailbox_id UUID)
|
||||||
|
RETURNS VOID
|
||||||
|
LANGUAGE sql
|
||||||
|
AS $$
|
||||||
|
INSERT INTO folders (mailbox_id, name, special_use)
|
||||||
|
SELECT p_mailbox_id, f.name, f.special_use
|
||||||
|
FROM (VALUES ('INBOX', 'inbox'),
|
||||||
|
('Sent', 'sent'),
|
||||||
|
('Drafts', 'drafts'),
|
||||||
|
('Trash', 'trash'),
|
||||||
|
('Junk', 'junk'),
|
||||||
|
('Archive', 'archive'),
|
||||||
|
-- Where delivered DMARC and TLS-RPT reports are filed. IMAP has no
|
||||||
|
-- special-use role for these, so it is matched by name.
|
||||||
|
('Reports', NULL)) AS f(name, special_use)
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM folders x
|
||||||
|
WHERE x.mailbox_id = p_mailbox_id
|
||||||
|
AND x.parent_id IS NULL
|
||||||
|
AND LOWER(x.name) = LOWER(f.name));
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Mailboxes created before the function that lack a standard folder get it now.
|
||||||
|
SELECT provision_default_folders(id) FROM mailboxes;
|
||||||
|
|
||||||
|
-- 2. Reconcile the cached numbers with the rows.
|
||||||
|
--
|
||||||
|
-- Folder counters, and the mailbox's used bytes, are denormalised caches. IMAP
|
||||||
|
-- and the webmail each maintained them and each got something wrong (IMAP never
|
||||||
|
-- moved the unread counter; the webmail never gave the quota back), so existing
|
||||||
|
-- mailboxes carry the drift. Every path now goes through one MailStore that
|
||||||
|
-- keeps them true; this corrects what is already stored, once. It only touches
|
||||||
|
-- rows that are actually wrong, so it is safe to run again.
|
||||||
|
UPDATE folders f
|
||||||
|
SET unread_count = c.unread,
|
||||||
|
total_count = c.total
|
||||||
|
FROM (
|
||||||
|
SELECT f2.id,
|
||||||
|
COUNT(e.id) FILTER (
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM message_flags mf
|
||||||
|
WHERE mf.message_id = e.id
|
||||||
|
AND mf.received_at = e.received_at
|
||||||
|
AND mf.flag = '\Seen')
|
||||||
|
)::int AS unread,
|
||||||
|
COUNT(e.id)::int AS total
|
||||||
|
FROM folders f2
|
||||||
|
LEFT JOIN email_messages e
|
||||||
|
ON e.folder_id = f2.id AND e.expunged_at IS NULL
|
||||||
|
GROUP BY f2.id
|
||||||
|
) AS c
|
||||||
|
WHERE f.id = c.id
|
||||||
|
AND (f.unread_count IS DISTINCT FROM c.unread
|
||||||
|
OR f.total_count IS DISTINCT FROM c.total);
|
||||||
|
|
||||||
|
UPDATE mailboxes m
|
||||||
|
SET used_bytes = COALESCE(s.total, 0)
|
||||||
|
FROM mailboxes m2
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT mailbox_id, SUM(size_bytes)::bigint AS total
|
||||||
|
FROM email_messages
|
||||||
|
WHERE expunged_at IS NULL
|
||||||
|
GROUP BY mailbox_id
|
||||||
|
) AS s ON s.mailbox_id = m2.id
|
||||||
|
WHERE m.id = m2.id
|
||||||
|
AND m.used_bytes IS DISTINCT FROM COALESCE(s.total, 0);
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, it, expect, afterAll } from "vitest";
|
||||||
|
|
||||||
|
process.env.LAMBDAMAIL_MASTER_KEY ||= "test-master-key-at-least-16-chars";
|
||||||
|
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL ?? "";
|
||||||
|
|
||||||
|
const hasDatabase = Boolean(process.env.TEST_DATABASE_URL);
|
||||||
|
const describeDb = hasDatabase ? describe : describe.skip;
|
||||||
|
|
||||||
|
const { createAdmin, CliError } = await import("./cli.js");
|
||||||
|
const { query, closePool } = await import("./db.js");
|
||||||
|
|
||||||
|
const DOMAIN = `cli-${Date.now()}.test`;
|
||||||
|
const EMAIL = `root@${DOMAIN}`;
|
||||||
|
|
||||||
|
describeDb("create-admin (command line)", () => {
|
||||||
|
afterAll(async () => {
|
||||||
|
await query(`DELETE FROM domains WHERE name = $1`, [DOMAIN]);
|
||||||
|
await closePool();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives the first administrator every standard folder, Reports included", async () => {
|
||||||
|
const log = console.log;
|
||||||
|
console.log = () => undefined;
|
||||||
|
try {
|
||||||
|
await createAdmin(EMAIL, "a-long-enough-password", true);
|
||||||
|
} finally {
|
||||||
|
console.log = log;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This account used to come out without Reports, so its DMARC and TLS-RPT
|
||||||
|
// reports fell into the INBOX: the console and this command each had their
|
||||||
|
// own list of folders and the lists had drifted.
|
||||||
|
const folders = await query<{ name: string; special_use: string | null }>(
|
||||||
|
`SELECT f.name, f.special_use FROM folders f JOIN mailboxes m ON m.id = f.mailbox_id WHERE m.email_address = $1`,
|
||||||
|
[EMAIL],
|
||||||
|
);
|
||||||
|
expect(folders.map((f) => f.name).sort()).toEqual(["Archive", "Drafts", "INBOX", "Junk", "Reports", "Sent", "Trash"]);
|
||||||
|
expect(folders.find((f) => f.name === "Reports")?.special_use).toBeNull();
|
||||||
|
expect(folders.find((f) => f.name === "Sent")?.special_use).toBe("sent");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses with a readable error instead of exiting the process", async () => {
|
||||||
|
await expect(createAdmin(EMAIL, "short", true)).rejects.toBeInstanceOf(CliError);
|
||||||
|
await expect(createAdmin(EMAIL, "a-long-enough-password", true)).rejects.toThrow(/already exists/);
|
||||||
|
});
|
||||||
|
});
|
||||||
+22
-25
@@ -13,22 +13,19 @@
|
|||||||
* node dist/cli.js reset-mfa <email>
|
* node dist/cli.js reset-mfa <email>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
import { query, queryOne, closePool } from "./db.js";
|
import { query, queryOne, closePool } from "./db.js";
|
||||||
import { hashPassword } from "./crypto.js";
|
import { hashPassword } from "./crypto.js";
|
||||||
|
|
||||||
const SYSTEM_ALIASES = ["postmaster", "abuse", "dmarc", "tlsrpt"];
|
const SYSTEM_ALIASES = ["postmaster", "abuse", "dmarc", "tlsrpt"];
|
||||||
const STANDARD_FOLDERS: Array<[string, string]> = [
|
|
||||||
["INBOX", "inbox"],
|
|
||||||
["Sent", "sent"],
|
|
||||||
["Drafts", "drafts"],
|
|
||||||
["Trash", "trash"],
|
|
||||||
["Junk", "junk"],
|
|
||||||
["Archive", "archive"],
|
|
||||||
];
|
|
||||||
|
|
||||||
|
/** A refusal the operator should read, as opposed to a bug. */
|
||||||
|
export class CliError extends Error {}
|
||||||
|
|
||||||
|
// Thrown rather than exiting here, so the commands can be exercised from a test;
|
||||||
|
// main() prints it as "error: ..." and exits 1, exactly as this used to.
|
||||||
function fail(message: string): never {
|
function fail(message: string): never {
|
||||||
console.error(`error: ${message}`);
|
throw new CliError(message);
|
||||||
process.exit(1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseAddress(email: string): { localPart: string; domain: string } {
|
function parseAddress(email: string): { localPart: string; domain: string } {
|
||||||
@@ -37,7 +34,7 @@ function parseAddress(email: string): { localPart: string; domain: string } {
|
|||||||
return { localPart: email.slice(0, at).toLowerCase(), domain: email.slice(at + 1).toLowerCase() };
|
return { localPart: email.slice(0, at).toLowerCase(), domain: email.slice(at + 1).toLowerCase() };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createAdmin(email: string, password: string, force: boolean): Promise<void> {
|
export async function createAdmin(email: string, password: string, force: boolean): Promise<void> {
|
||||||
if (password.length < 12) fail("the password must be at least 12 characters");
|
if (password.length < 12) fail("the password must be at least 12 characters");
|
||||||
const { localPart, domain } = parseAddress(email);
|
const { localPart, domain } = parseAddress(email);
|
||||||
|
|
||||||
@@ -69,13 +66,10 @@ async function createAdmin(email: string, password: string, force: boolean): Pro
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Delivery needs these: a mailbox with no INBOX cannot receive at all, and
|
// Delivery needs these: a mailbox with no INBOX cannot receive at all, and
|
||||||
// one with no Junk used to fail the spam path outright.
|
// one with no Junk used to fail the spam path outright. The list lives in the
|
||||||
for (const [name, specialUse] of STANDARD_FOLDERS) {
|
// database (migration 0014) so the console and this command cannot disagree
|
||||||
await query(
|
// about it, as they once did over the Reports folder.
|
||||||
`INSERT INTO folders (mailbox_id, name, special_use) VALUES ($1, $2, $3)`,
|
await query(`SELECT provision_default_folders($1)`, [mailbox!.id]);
|
||||||
[mailbox!.id, name, specialUse],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// PLAN.md section 7.4b: the published DNS records name these, and reports
|
// PLAN.md section 7.4b: the published DNS records name these, and reports
|
||||||
// bounce if they do not resolve.
|
// bounce if they do not resolve.
|
||||||
@@ -174,10 +168,13 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
main()
|
// Only when run as a command, not when a test imports the functions above
|
||||||
.then(() => closePool())
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||||
.catch(async (err) => {
|
main()
|
||||||
console.error(`error: ${err instanceof Error ? err.message : String(err)}`);
|
.then(() => closePool())
|
||||||
await closePool().catch(() => undefined);
|
.catch(async (err) => {
|
||||||
process.exit(1);
|
console.error(`error: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
});
|
await closePool().catch(() => undefined);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1004,18 +1004,9 @@ export async function createMailbox(scope: AdminScope, input: CreateMailboxInput
|
|||||||
[input.domainId, localPart, email, hash, input.role, input.quotaBytes],
|
[input.domainId, localPart, email, hash, input.role, input.quotaBytes],
|
||||||
);
|
);
|
||||||
|
|
||||||
await query(
|
// The standard folders come from one definition in the database (migration
|
||||||
`INSERT INTO folders (mailbox_id, name, special_use)
|
// 0014), shared with the command that creates the first administrator.
|
||||||
SELECT $1, f.name, f.special_use
|
await query(`SELECT provision_default_folders($1)`, [created!.id]);
|
||||||
FROM (VALUES ('INBOX','inbox'), ('Sent','sent'), ('Drafts','drafts'),
|
|
||||||
('Trash','trash'), ('Junk','junk'), ('Archive','archive'),
|
|
||||||
-- Where delivered DMARC and TLS-RPT reports are filed.
|
|
||||||
-- IMAP has no special-use role for these, so it is matched
|
|
||||||
-- by name. Without the folder the delivery path falls back
|
|
||||||
-- to INBOX, which is the noise this is here to remove.
|
|
||||||
('Reports', NULL)) AS f(name, special_use)`,
|
|
||||||
[created!.id],
|
|
||||||
);
|
|
||||||
|
|
||||||
return { status: "OK", id: created!.id, email };
|
return { status: "OK", id: created!.id, email };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,3 +137,51 @@ func TestSubmit_WorksWithoutASentCopyRepository(t *testing.T) {
|
|||||||
t.Fatalf("Submit without a Sent repository: %v", err)
|
t.Fatalf("Submit without a Sent repository: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One rule for every path: a message sent from the webmail and the same message
|
||||||
|
// sent by an SMTP client leave the same thing behind in Sent. They used to differ
|
||||||
|
// completely - the webmail filed a copy, SMTP filed nothing - because the rule
|
||||||
|
// lived in the webmail alone.
|
||||||
|
func TestSentCopyIsTheSameWhicheverPathSent(t *testing.T) {
|
||||||
|
mailboxID := uuid.New()
|
||||||
|
messages := &recordingMessageRepository{}
|
||||||
|
submission := NewProcessOutboundEmailUseCase(nil, &countingOutboundRepo{}, &capturingBlobStorage{}, nil)
|
||||||
|
submission.SetSentCopies(messages)
|
||||||
|
|
||||||
|
account := &port.MailboxAuth{ID: mailboxID, EmailAddress: "me@example.test", DomainName: "example.test", MaxRecipientsPerHour: 100}
|
||||||
|
web := &WebmailUseCase{
|
||||||
|
repo: &stubWebmailRepo{mailboxID: mailboxID.String()},
|
||||||
|
auth: &stubAuthRepo{account: account},
|
||||||
|
submission: submission,
|
||||||
|
localHost: "mail.example.test",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := web.Send(context.Background(), ComposeInput{From: "me@example.test", To: []string{"you@remote.test"}, Subject: "Aviso", Text: "hi"}); err != nil {
|
||||||
|
t.Fatalf("webmail send: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
smtp := ProcessOutboundEmailInput{
|
||||||
|
MailboxID: mailboxID, SenderAddr: "me@example.test", DomainName: "example.test",
|
||||||
|
Recipients: []string{"you@remote.test"}, FileSentCopy: true,
|
||||||
|
Body: bytes.NewBufferString("From: me@example.test\r\nTo: you@remote.test\r\nSubject: Aviso\r\n\r\nhi"),
|
||||||
|
}
|
||||||
|
if err := submission.Submit(context.Background(), smtp); err != nil {
|
||||||
|
t.Fatalf("smtp submit: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(messages.persisted) != 2 {
|
||||||
|
t.Fatalf("want one copy per path (2), got %d: the webmail must not file its own copy as well", len(messages.persisted))
|
||||||
|
}
|
||||||
|
fromWeb, fromSMTP := messages.persisted[0], messages.persisted[1]
|
||||||
|
for _, got := range []port.PersistInboundMessageInput{fromWeb, fromSMTP} {
|
||||||
|
if got.TargetFolderName != "Sent" || !got.AlreadySeen || got.MailboxID != mailboxID {
|
||||||
|
t.Errorf("a Sent copy is not what the rule says: %+v", got)
|
||||||
|
}
|
||||||
|
if got.SenderAddress != "me@example.test" || got.RecipientAddress != "me@example.test" {
|
||||||
|
t.Errorf("a Sent copy belongs to its sender: %+v", got)
|
||||||
|
}
|
||||||
|
if got.Subject != "Aviso" {
|
||||||
|
t.Errorf("subject = %q, want Aviso", got.Subject)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -197,6 +197,8 @@ func newSendableUseCaseWith(
|
|||||||
}
|
}
|
||||||
submission := NewProcessOutboundEmailUseCase(
|
submission := NewProcessOutboundEmailUseCase(
|
||||||
nil, &countingOutboundRepo{}, &capturingBlobStorage{}, &recordingSigner{})
|
nil, &countingOutboundRepo{}, &capturingBlobStorage{}, &recordingSigner{})
|
||||||
|
// The Sent copy is filed by the submission, as it is for SMTP clients
|
||||||
|
submission.SetSentCopies(messages)
|
||||||
return (&WebmailUseCase{
|
return (&WebmailUseCase{
|
||||||
repo: repo,
|
repo: repo,
|
||||||
auth: &stubAuthRepo{account: account},
|
auth: &stubAuthRepo{account: account},
|
||||||
|
|||||||
@@ -32,10 +32,9 @@ type WebmailUseCase struct {
|
|||||||
submission *ProcessOutboundEmailUseCase
|
submission *ProcessOutboundEmailUseCase
|
||||||
auth port.AuthRepository
|
auth port.AuthRepository
|
||||||
localHost string
|
localHost string
|
||||||
// blobStore and messages file the sender's own copy into Sent, and hold
|
// blobStore and messages hold drafts. Both are optional: when either is nil
|
||||||
// drafts. Both are optional: when either is nil the copy is skipped rather
|
// saving a draft reports ErrDraftsUnavailable. (The copy in Sent is filed by
|
||||||
// than the send failing, because a message that has already been queued
|
// the submission use case, not here.)
|
||||||
// for delivery must not be reported as an error.
|
|
||||||
blobStore port.BlobStorage
|
blobStore port.BlobStorage
|
||||||
messages port.InboundMessageRepository
|
messages port.InboundMessageRepository
|
||||||
}
|
}
|
||||||
@@ -53,7 +52,7 @@ func NewWebmailUseCase(
|
|||||||
return &WebmailUseCase{repo: repo, blobs: blobs, submission: submission, auth: auth, localHost: localHost}
|
return &WebmailUseCase{repo: repo, blobs: blobs, submission: submission, auth: auth, localHost: localHost}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithLocalFiling enables the Sent copy and draft storage.
|
// WithLocalFiling enables draft storage.
|
||||||
func (uc *WebmailUseCase) WithLocalFiling(store port.BlobStorage, messages port.InboundMessageRepository) *WebmailUseCase {
|
func (uc *WebmailUseCase) WithLocalFiling(store port.BlobStorage, messages port.InboundMessageRepository) *WebmailUseCase {
|
||||||
uc.blobStore = store
|
uc.blobStore = store
|
||||||
uc.messages = messages
|
uc.messages = messages
|
||||||
@@ -188,6 +187,10 @@ func (uc *WebmailUseCase) Send(ctx context.Context, input ComposeInput) error {
|
|||||||
|
|
||||||
payload := buildMimeMessage(input, recipients, uc.localHost)
|
payload := buildMimeMessage(input, recipients, uc.localHost)
|
||||||
|
|
||||||
|
// The copy in Sent is filed by Submit, the same way it is for a client that
|
||||||
|
// sends over SMTP: one rule, one place. It used to be filed here, after the
|
||||||
|
// submission, and not at all for SMTP, so an account that sent by SMTP had an
|
||||||
|
// empty Sent folder.
|
||||||
if err := uc.submission.Submit(ctx, ProcessOutboundEmailInput{
|
if err := uc.submission.Submit(ctx, ProcessOutboundEmailInput{
|
||||||
MailboxID: account.ID,
|
MailboxID: account.ID,
|
||||||
SenderAddr: account.EmailAddress,
|
SenderAddr: account.EmailAddress,
|
||||||
@@ -195,19 +198,11 @@ func (uc *WebmailUseCase) Send(ctx context.Context, input ComposeInput) error {
|
|||||||
Recipients: recipients,
|
Recipients: recipients,
|
||||||
Body: bytes.NewReader(payload),
|
Body: bytes.NewReader(payload),
|
||||||
MaxRecipientsPerHour: account.MaxRecipientsPerHour,
|
MaxRecipientsPerHour: account.MaxRecipientsPerHour,
|
||||||
|
FileSentCopy: true,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only after the message is durably queued. Filing it first would leave a
|
|
||||||
// copy in Sent for a message that was then rejected by the send limit.
|
|
||||||
//
|
|
||||||
// A failure here is logged and swallowed: the mail is already on its way,
|
|
||||||
// and telling the composer the send failed would invite a duplicate.
|
|
||||||
if err := uc.fileLocalCopy(ctx, account.ID, account.EmailAddress, "Sent", payload); err != nil {
|
|
||||||
log.Printf("webmail: could not file the Sent copy for %s: %v", account.EmailAddress, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The draft this message grew out of has been superseded by the real
|
// The draft this message grew out of has been superseded by the real
|
||||||
// thing. Swallowed like the Sent copy above and for the same reason: the
|
// thing. Swallowed like the Sent copy above and for the same reason: the
|
||||||
// mail is already queued, and failing the request now would only invite a
|
// mail is already queued, and failing the request now would only invite a
|
||||||
@@ -288,36 +283,6 @@ func (uc *WebmailUseCase) Delete(ctx context.Context, address, folder string, ui
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// fileLocalCopy stores a message the user themselves composed into one of
|
|
||||||
// their own folders. It reuses the inbound persistence path so the copy gets a
|
|
||||||
// real IMAP UID and shows up over IMAP as well as in the webmail.
|
|
||||||
func (uc *WebmailUseCase) fileLocalCopy(
|
|
||||||
ctx context.Context, mailboxID uuid.UUID, address, folder string, payload []byte,
|
|
||||||
) error {
|
|
||||||
if uc.blobStore == nil || uc.messages == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
blob, err := uc.blobStore.Store(ctx, bytes.NewReader(payload))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
headers := ExtractMessageHeaders(payload)
|
|
||||||
_, err = uc.messages.PersistAll(ctx, []port.PersistInboundMessageInput{{
|
|
||||||
MailboxID: mailboxID,
|
|
||||||
Blob: blob,
|
|
||||||
SenderAddress: address,
|
|
||||||
RecipientAddress: address,
|
|
||||||
TargetFolderName: folder,
|
|
||||||
Subject: headers.Subject,
|
|
||||||
Snippet: headers.Snippet,
|
|
||||||
FromDisplayName: headers.FromDisplayName,
|
|
||||||
MessageIDHeader: headers.MessageID,
|
|
||||||
HasAttachments: headers.HasAttachments,
|
|
||||||
AlreadySeen: true,
|
|
||||||
}})
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// SaveDraft files the message being composed into the Drafts folder.
|
// SaveDraft files the message being composed into the Drafts folder.
|
||||||
//
|
//
|
||||||
// Nothing did this before: the composer displayed "draft saved automatically"
|
// Nothing did this before: the composer displayed "draft saved automatically"
|
||||||
|
|||||||
@@ -0,0 +1,385 @@
|
|||||||
|
package e2e
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/alexedwards/argon2id"
|
||||||
|
"github.com/emersion/go-imap/v2"
|
||||||
|
"github.com/emersion/go-imap/v2/imapclient"
|
||||||
|
"github.com/emersion/go-imap/v2/imapserver"
|
||||||
|
gosmtp "github.com/emersion/go-smtp"
|
||||||
|
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"lambdamail/protocols/internal/application/port"
|
||||||
|
appusecase "lambdamail/protocols/internal/application/usecase"
|
||||||
|
"lambdamail/protocols/internal/infrastructure/diskstorage"
|
||||||
|
"lambdamail/protocols/internal/infrastructure/dkim"
|
||||||
|
"lambdamail/protocols/internal/infrastructure/postgres"
|
||||||
|
httppresentation "lambdamail/protocols/internal/presentation/http"
|
||||||
|
imappresentation "lambdamail/protocols/internal/presentation/imap"
|
||||||
|
smtppresentation "lambdamail/protocols/internal/presentation/smtp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestOneMailboxEverywhere is the contract of the whole server: SMTP, IMAP and
|
||||||
|
// the webmail are three ways into the same mailbox, so whatever one does, the
|
||||||
|
// other two must see, with the same numbers.
|
||||||
|
//
|
||||||
|
// The webmail used to carry its own copy of the rules and its own queries, and
|
||||||
|
// the two stacks drifted apart: mail sent over SMTP never reached Sent, reading
|
||||||
|
// over IMAP never moved the unread badge, deleting in the webmail never gave the
|
||||||
|
// quota back. This test drives all three on one mailbox, mixing them, and checks
|
||||||
|
// after every step that each view agrees with the others and with the data.
|
||||||
|
func TestOneMailboxEverywhere(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("e2e test skipped in -short mode")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
pg := embeddedpostgres.NewDatabase(embeddedpostgres.DefaultConfig().
|
||||||
|
Port(15451).
|
||||||
|
RuntimePath(t.TempDir()).
|
||||||
|
StartTimeout(120 * time.Second))
|
||||||
|
if err := pg.Start(); err != nil {
|
||||||
|
t.Fatalf("start embedded postgres: %v", err)
|
||||||
|
}
|
||||||
|
defer pg.Stop()
|
||||||
|
|
||||||
|
pool, err := postgres.NewPool(ctx, "postgres://postgres:postgres@localhost:15451/postgres?sslmode=disable")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect pool: %v", err)
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
applyMigrations(t, ctx, pool)
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- fixtures
|
||||||
|
const (
|
||||||
|
address = "plataforma@everywhere.test"
|
||||||
|
password = "a long enough password"
|
||||||
|
)
|
||||||
|
hash, err := argon2id.CreateHash(password, argon2id.DefaultParams)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("hash: %v", err)
|
||||||
|
}
|
||||||
|
domainID, mailboxID := uuid.New(), uuid.New()
|
||||||
|
pool.Exec(ctx, `INSERT INTO domains (id, name, punycode_name) VALUES ($1, 'everywhere.test', 'everywhere.test')`, domainID)
|
||||||
|
pool.Exec(ctx, `INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash) VALUES ($1, $2, 'plataforma', $3, $4)`,
|
||||||
|
mailboxID, domainID, address, hash)
|
||||||
|
// The standard folders come from the one definition in the database
|
||||||
|
if _, err := pool.Exec(ctx, `SELECT provision_default_folders($1)`, mailboxID); err != nil {
|
||||||
|
t.Fatalf("provision folders: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
blobs := diskstorage.NewLocalDiskBlobStorage(pool, t.TempDir())
|
||||||
|
blobReader := diskstorage.NewLocalDiskBlobReader(pool)
|
||||||
|
authRepo := postgres.NewAuthRepository(pool)
|
||||||
|
messages := postgres.NewInboundMessageRepository(pool)
|
||||||
|
|
||||||
|
// Three messages arrive from outside, as the SMTP side would file them
|
||||||
|
inbound := appusecase.NewProcessInboundEmailUseCase(postgres.NewMailboxRepository(pool), blobs, messages)
|
||||||
|
target, err := postgres.NewMailboxRepository(pool).FindActiveByAddress(ctx, address)
|
||||||
|
if err != nil || target == nil {
|
||||||
|
t.Fatalf("resolve mailbox: %v", err)
|
||||||
|
}
|
||||||
|
for i := 1; i <= 3; i++ {
|
||||||
|
raw := fmt.Sprintf("From: alice@remote.test\r\nTo: %s\r\nSubject: inbound %d\r\nMessage-ID: <in%d@remote.test>\r\n\r\nbody %d\r\n", address, i, i, i)
|
||||||
|
if err := inbound.Handle(ctx, appusecase.ProcessInboundEmailInput{
|
||||||
|
Sender: "alice@remote.test", Recipients: []port.MailboxRecord{*target}, RecipientAddresses: []string{address}, Body: strings.NewReader(raw),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("deliver %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------- the three ways in, wired as main.go
|
||||||
|
submissionUC := appusecase.NewProcessOutboundEmailUseCase(authRepo, postgres.NewOutboundRepository(pool), blobs, dkim.NewSigner(nil))
|
||||||
|
submissionUC.SetSentCopies(messages)
|
||||||
|
|
||||||
|
smtpServer := gosmtp.NewServer(smtppresentation.NewSubmissionBackend(submissionUC))
|
||||||
|
smtpServer.Domain = "mail.everywhere.test"
|
||||||
|
smtpServer.TLSConfig = &tls.Config{Certificates: []tls.Certificate{selfSignedTLSCert(t)}}
|
||||||
|
smtpLn, _ := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
defer smtpLn.Close()
|
||||||
|
go smtpServer.Serve(smtpLn)
|
||||||
|
|
||||||
|
imapUC := appusecase.NewImapSessionUseCase(
|
||||||
|
authRepo, postgres.NewImapFolderRepository(pool), postgres.NewMessageQueryRepository(pool),
|
||||||
|
postgres.NewFlagRepository(pool), blobReader, postgres.NewExpungeRepository(pool), postgres.NewCopyRepository(pool))
|
||||||
|
imapServer := imapserver.New(&imapserver.Options{
|
||||||
|
NewSession: func(c *imapserver.Conn) (imapserver.Session, *imapserver.GreetingData, error) {
|
||||||
|
return imappresentation.NewSession(c, imapUC)
|
||||||
|
},
|
||||||
|
Caps: imap.CapSet{imap.CapIMAP4rev1: {}, imap.CapMove: {}, imap.CapUIDPlus: {}},
|
||||||
|
InsecureAuth: true,
|
||||||
|
})
|
||||||
|
imapLn, _ := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
go imapServer.Serve(imapLn)
|
||||||
|
defer imapServer.Close()
|
||||||
|
|
||||||
|
webmailUC := appusecase.NewWebmailUseCase(postgres.NewWebmailRepository(pool), blobReader, submissionUC, authRepo, "mail.everywhere.test").
|
||||||
|
WithLocalFiling(blobs, messages)
|
||||||
|
router := httppresentation.NewRouter(nil, func() error { return nil })
|
||||||
|
router.SetMailAPI(webmailUC, webmailSecret)
|
||||||
|
web := httptest.NewServer(router)
|
||||||
|
defer web.Close()
|
||||||
|
token := mintSession(t, address, "user", time.Hour)
|
||||||
|
|
||||||
|
// --------------------------------------------------------- small drivers
|
||||||
|
webCall := func(method, path string, body any) []byte {
|
||||||
|
t.Helper()
|
||||||
|
var reader *bytes.Reader
|
||||||
|
if body != nil {
|
||||||
|
encoded, _ := json.Marshal(body)
|
||||||
|
reader = bytes.NewReader(encoded)
|
||||||
|
} else {
|
||||||
|
reader = bytes.NewReader(nil)
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequest(method, web.URL+path, reader)
|
||||||
|
req.Header.Set("Cookie", "lm_user_session="+token)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("webmail %s %s: %v", method, path, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
buf.ReadFrom(resp.Body)
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
t.Fatalf("webmail %s %s = %d: %s", method, path, resp.StatusCode, buf.String())
|
||||||
|
}
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
type webFolder struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
UnreadCount int `json:"unread_count"`
|
||||||
|
TotalCount int `json:"total_count"`
|
||||||
|
}
|
||||||
|
webFolders := func() map[string]webFolder {
|
||||||
|
t.Helper()
|
||||||
|
var payload struct {
|
||||||
|
Folders []webFolder `json:"folders"`
|
||||||
|
}
|
||||||
|
raw := webCall(http.MethodGet, "/api/v1/mail/folders", nil)
|
||||||
|
if err := json.Unmarshal(raw, &payload); err != nil || payload.Folders == nil {
|
||||||
|
var plain []webFolder
|
||||||
|
if err2 := json.Unmarshal(raw, &plain); err2 != nil {
|
||||||
|
t.Fatalf("decode folders: %v / %v: %s", err, err2, raw)
|
||||||
|
}
|
||||||
|
payload.Folders = plain
|
||||||
|
}
|
||||||
|
out := map[string]webFolder{}
|
||||||
|
for _, f := range payload.Folders {
|
||||||
|
out[f.Name] = f
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
type webMessage struct {
|
||||||
|
UID uint32 `json:"uid"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Seen bool `json:"seen"`
|
||||||
|
}
|
||||||
|
webMessages := func(folder string) []webMessage {
|
||||||
|
t.Helper()
|
||||||
|
var payload struct {
|
||||||
|
Messages []webMessage `json:"messages"`
|
||||||
|
}
|
||||||
|
raw := webCall(http.MethodGet, "/api/v1/mail/messages?folder="+folder, nil)
|
||||||
|
if err := json.Unmarshal(raw, &payload); err != nil || payload.Messages == nil {
|
||||||
|
var plain []webMessage
|
||||||
|
if err2 := json.Unmarshal(raw, &plain); err2 != nil {
|
||||||
|
t.Fatalf("decode messages: %v / %v: %s", err, err2, raw)
|
||||||
|
}
|
||||||
|
payload.Messages = plain
|
||||||
|
}
|
||||||
|
return payload.Messages
|
||||||
|
}
|
||||||
|
|
||||||
|
imapClient, err := imapclient.DialInsecure(imapLn.Addr().String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dial imap: %v", err)
|
||||||
|
}
|
||||||
|
defer imapClient.Close()
|
||||||
|
if err := imapClient.Login(address, password).Wait(); err != nil {
|
||||||
|
t.Fatalf("imap login: %v", err)
|
||||||
|
}
|
||||||
|
imapExists := func(folder string) int {
|
||||||
|
t.Helper()
|
||||||
|
data, err := imapClient.Select(folder, nil).Wait()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("imap select %s: %v", folder, err)
|
||||||
|
}
|
||||||
|
return int(data.NumMessages)
|
||||||
|
}
|
||||||
|
imapFlags := func(folder string) map[uint32][]imap.Flag {
|
||||||
|
t.Helper()
|
||||||
|
imapExists(folder)
|
||||||
|
all := imap.UIDSet{}
|
||||||
|
all.AddRange(1, 0) // 1:*
|
||||||
|
cmd := imapClient.Fetch(all, &imap.FetchOptions{UID: true, Flags: true})
|
||||||
|
out := map[uint32][]imap.Flag{}
|
||||||
|
msgs, err := cmd.Collect()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("imap fetch flags in %s: %v", folder, err)
|
||||||
|
}
|
||||||
|
for _, m := range msgs {
|
||||||
|
out[uint32(m.UID)] = m.Flags
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// agree compares what the webmail and IMAP each say about every folder, and
|
||||||
|
// both with the rows: the one thing that must never differ
|
||||||
|
agree := func(after string) {
|
||||||
|
t.Helper()
|
||||||
|
assertEverywhere := webFolders()
|
||||||
|
for _, name := range []string{"INBOX", "Sent", "Trash", "Drafts"} {
|
||||||
|
if got, want := imapExists(name), assertEverywhere[name].TotalCount; got != want {
|
||||||
|
t.Errorf("after %s: %s holds %d messages over IMAP and %d in the webmail", after, name, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var badFolders, badBytes int
|
||||||
|
pool.QueryRow(ctx, `
|
||||||
|
SELECT COUNT(*) FROM (
|
||||||
|
SELECT f.id FROM folders f
|
||||||
|
LEFT JOIN email_messages e ON e.folder_id = f.id AND e.expunged_at IS NULL
|
||||||
|
WHERE f.mailbox_id = $1
|
||||||
|
GROUP BY f.id, f.total_count, f.unread_count
|
||||||
|
HAVING f.total_count <> COUNT(e.id)
|
||||||
|
OR f.unread_count <> COUNT(e.id) FILTER (WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM message_flags mf WHERE mf.message_id = e.id AND mf.received_at = e.received_at AND mf.flag = '\Seen'))
|
||||||
|
) x`, mailboxID).Scan(&badFolders)
|
||||||
|
pool.QueryRow(ctx, `
|
||||||
|
SELECT COUNT(*) FROM mailboxes m
|
||||||
|
WHERE m.id = $1 AND m.used_bytes <> COALESCE((SELECT SUM(size_bytes) FROM email_messages WHERE mailbox_id = m.id AND expunged_at IS NULL), 0)`,
|
||||||
|
mailboxID).Scan(&badBytes)
|
||||||
|
if badFolders != 0 {
|
||||||
|
t.Errorf("after %s: %d folder(s) have cached counters that differ from their messages", after, badFolders)
|
||||||
|
}
|
||||||
|
if badBytes != 0 {
|
||||||
|
t.Errorf("after %s: the quota differs from the bytes actually stored", after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
agree("delivery")
|
||||||
|
if f := webFolders(); f["INBOX"].UnreadCount != 3 || f["INBOX"].TotalCount != 3 {
|
||||||
|
t.Fatalf("INBOX should start with 3 unread of 3, the webmail says %+v", f["INBOX"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- 1. SMTP sends a message; Sent must show it in the webmail and over IMAP
|
||||||
|
smtpClient, err := gosmtp.DialStartTLS(smtpLn.Addr().String(), &tls.Config{InsecureSkipVerify: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dial smtp: %v", err)
|
||||||
|
}
|
||||||
|
if err := smtpClient.Auth(plainAuth(address, password)); err != nil {
|
||||||
|
t.Fatalf("smtp auth: %v", err)
|
||||||
|
}
|
||||||
|
smtpClient.Mail(address, nil)
|
||||||
|
smtpClient.Rcpt("bob@remote.test", nil)
|
||||||
|
w, _ := smtpClient.Data()
|
||||||
|
w.Write([]byte("From: " + address + "\r\nTo: bob@remote.test\r\nSubject: sent over smtp\r\nMessage-ID: <smtp1@everywhere.test>\r\n\r\nhello\r\n"))
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
t.Fatalf("smtp data: %v", err)
|
||||||
|
}
|
||||||
|
smtpClient.Quit()
|
||||||
|
|
||||||
|
agree("sending over SMTP")
|
||||||
|
if sent := webMessages("sent"); len(sent) != 1 || sent[0].Subject != "sent over smtp" {
|
||||||
|
t.Fatalf("the webmail's Sent should hold the SMTP message, got %+v", sent)
|
||||||
|
}
|
||||||
|
if n := imapExists("Sent"); n != 1 {
|
||||||
|
t.Fatalf("IMAP's Sent holds %d messages after an SMTP send, want 1", n)
|
||||||
|
}
|
||||||
|
if f := webFolders(); f["Sent"].UnreadCount != 0 {
|
||||||
|
t.Errorf("the sender's own copy shows as unread: %+v", f["Sent"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- 2. the webmail sends one too: it must land in the same place, once
|
||||||
|
webCall(http.MethodPost, "/api/v1/mail/send", map[string]any{"to": []string{"carol@remote.test"}, "subject": "sent from webmail", "text": "hi"})
|
||||||
|
agree("sending from the webmail")
|
||||||
|
if n := imapExists("Sent"); n != 2 {
|
||||||
|
t.Fatalf("Sent holds %d messages after one SMTP send and one webmail send, want exactly 2", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- 3. read in the webmail; IMAP must see \Seen and the badge must fall
|
||||||
|
inbox := webMessages("inbox")
|
||||||
|
if len(inbox) != 3 {
|
||||||
|
t.Fatalf("webmail lists %d INBOX messages, want 3", len(inbox))
|
||||||
|
}
|
||||||
|
uidA, uidB, uidC := inbox[2].UID, inbox[1].UID, inbox[0].UID // oldest first by UID
|
||||||
|
webCall(http.MethodPost, "/api/v1/mail/seen", map[string]any{"folder": "inbox", "uid": uidA, "seen": true})
|
||||||
|
agree("reading a message in the webmail")
|
||||||
|
if flags := imapFlags("INBOX")[uidA]; !hasFlag(flags, imap.FlagSeen) {
|
||||||
|
t.Errorf("IMAP does not see the message the webmail marked as read: %v", flags)
|
||||||
|
}
|
||||||
|
if f := webFolders(); f["INBOX"].UnreadCount != 2 {
|
||||||
|
t.Errorf("INBOX unread = %d after one read, want 2", f["INBOX"].UnreadCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- 4. read over IMAP; the webmail's badge must follow (it never did)
|
||||||
|
imapExists("INBOX")
|
||||||
|
if err := imapClient.Store(imap.UIDSetNum(imap.UID(uidB)), &imap.StoreFlags{Op: imap.StoreFlagsAdd, Silent: true, Flags: []imap.Flag{imap.FlagSeen}}, nil).Close(); err != nil {
|
||||||
|
t.Fatalf("imap store: %v", err)
|
||||||
|
}
|
||||||
|
agree("reading a message over IMAP")
|
||||||
|
if f := webFolders(); f["INBOX"].UnreadCount != 1 {
|
||||||
|
t.Errorf("INBOX unread = %d after a read over IMAP, want 1: the badge ignored it", f["INBOX"].UnreadCount)
|
||||||
|
}
|
||||||
|
for _, m := range webMessages("inbox") {
|
||||||
|
if m.UID == uidB && !m.Seen {
|
||||||
|
t.Error("the webmail lists the message IMAP marked as read as unread")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- 5. delete over IMAP (\Deleted + EXPUNGE); the webmail list shrinks, the quota is returned
|
||||||
|
imapExists("INBOX")
|
||||||
|
if err := imapClient.Store(imap.UIDSetNum(imap.UID(uidC)), &imap.StoreFlags{Op: imap.StoreFlagsAdd, Silent: true, Flags: []imap.Flag{imap.FlagDeleted}}, nil).Close(); err != nil {
|
||||||
|
t.Fatalf("imap store deleted: %v", err)
|
||||||
|
}
|
||||||
|
if err := imapClient.Expunge().Close(); err != nil {
|
||||||
|
t.Fatalf("imap expunge: %v", err)
|
||||||
|
}
|
||||||
|
agree("deleting over IMAP")
|
||||||
|
if n := len(webMessages("inbox")); n != 2 {
|
||||||
|
t.Errorf("the webmail lists %d INBOX messages after an IMAP delete, want 2", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- 6. delete in the webmail (to Trash); IMAP sees it in Trash and not in INBOX
|
||||||
|
webCall(http.MethodPost, "/api/v1/mail/delete", map[string]any{"folder": "inbox", "uid": uidA})
|
||||||
|
agree("deleting in the webmail")
|
||||||
|
if n := imapExists("Trash"); n != 1 {
|
||||||
|
t.Errorf("IMAP's Trash holds %d messages, want 1", n)
|
||||||
|
}
|
||||||
|
if n := imapExists("INBOX"); n != 1 {
|
||||||
|
t.Errorf("IMAP's INBOX holds %d messages, want 1", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- 7. empty the Trash in the webmail: the bytes go back to the quota
|
||||||
|
trash := webMessages("trash")
|
||||||
|
if len(trash) != 1 {
|
||||||
|
t.Fatalf("the webmail lists %d Trash messages, want 1", len(trash))
|
||||||
|
}
|
||||||
|
webCall(http.MethodPost, "/api/v1/mail/delete", map[string]any{"folder": "trash", "uid": trash[0].UID})
|
||||||
|
agree("emptying the Trash")
|
||||||
|
if n := imapExists("Trash"); n != 0 {
|
||||||
|
t.Errorf("IMAP's Trash holds %d messages after emptying it, want 0", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasFlag(flags []imap.Flag, want imap.Flag) bool {
|
||||||
|
for _, f := range flags {
|
||||||
|
if f == want {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -2,133 +2,21 @@ package postgres
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
"lambdamail/protocols/internal/application/port"
|
"lambdamail/protocols/internal/application/port"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CopyRepository implements port.CopyRepository against Postgres. One
|
// CopyRepository implements port.CopyRepository (IMAP COPY) over MailStore.
|
||||||
// transaction per copied message, mirroring InboundMessageRepository's
|
|
||||||
// established per-message transactional pattern (PLAN.md section 6.1).
|
|
||||||
type CopyRepository struct {
|
type CopyRepository struct {
|
||||||
pool *pgxpool.Pool
|
store *MailStore
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCopyRepository(pool *pgxpool.Pool) *CopyRepository {
|
func NewCopyRepository(pool *pgxpool.Pool) *CopyRepository {
|
||||||
return &CopyRepository{pool: pool}
|
return &CopyRepository{store: NewMailStore(pool)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *CopyRepository) CopyMessages(ctx context.Context, sourceFolderID string, uids []uint32, destFolderID string) ([]port.CopiedMessage, error) {
|
func (r *CopyRepository) CopyMessages(ctx context.Context, sourceFolderID string, uids []uint32, destFolderID string) ([]port.CopiedMessage, error) {
|
||||||
results := make([]port.CopiedMessage, 0, len(uids))
|
return r.store.Copy(ctx, sourceFolderID, uids, destFolderID)
|
||||||
for _, uid := range uids {
|
|
||||||
destUID, err := r.copyOne(ctx, sourceFolderID, uid, destFolderID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("copy uid %d: %w", uid, err)
|
|
||||||
}
|
|
||||||
results = append(results, port.CopiedMessage{SourceUID: uid, DestUID: destUID})
|
|
||||||
}
|
|
||||||
return results, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *CopyRepository) copyOne(ctx context.Context, sourceFolderID string, sourceUID uint32, destFolderID string) (uint32, error) {
|
|
||||||
tx, err := r.pool.Begin(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("begin transaction: %w", err)
|
|
||||||
}
|
|
||||||
defer tx.Rollback(ctx)
|
|
||||||
|
|
||||||
var sourceMessageID string
|
|
||||||
var blobID, senderAddress string
|
|
||||||
var recipientAddresses []string
|
|
||||||
var sizeBytes int64
|
|
||||||
var spfResult, dkimResult, dmarcResult *string
|
|
||||||
var fromDisplayName, subject, snippet, messageIDHeader, inReplyTo *string
|
|
||||||
var threadID *uuid.UUID
|
|
||||||
var hasAttachments bool
|
|
||||||
var arcResult, daneStatus, tlsVersion, spamVerdict, virusName *string
|
|
||||||
var spamScore float64
|
|
||||||
err = tx.QueryRow(ctx, `
|
|
||||||
SELECT id, blob_id, sender_address, recipient_addresses, size_bytes, spf_result, dkim_result, dmarc_result,
|
|
||||||
from_display_name, subject, snippet, message_id_header, in_reply_to, thread_id, has_attachments,
|
|
||||||
arc_result, dane_status, tls_version, spam_score, spam_verdict, virus_name
|
|
||||||
FROM email_messages WHERE folder_id = $1 AND uid = $2 AND expunged_at IS NULL
|
|
||||||
`, sourceFolderID, sourceUID).Scan(&sourceMessageID, &blobID, &senderAddress, &recipientAddresses, &sizeBytes, &spfResult, &dkimResult, &dmarcResult,
|
|
||||||
&fromDisplayName, &subject, &snippet, &messageIDHeader, &inReplyTo, &threadID, &hasAttachments,
|
|
||||||
&arcResult, &daneStatus, &tlsVersion, &spamScore, &spamVerdict, &virusName)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("find source message: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// uid_next is BIGINT; the destination UID we allocate for this message
|
|
||||||
// is the folder's current uid_next value before advancing it.
|
|
||||||
var destUIDNext int64
|
|
||||||
if err := tx.QueryRow(ctx, `
|
|
||||||
SELECT uid_next FROM folders WHERE id = $1 FOR UPDATE
|
|
||||||
`, destFolderID).Scan(&destUIDNext); err != nil {
|
|
||||||
return 0, fmt.Errorf("lock destination folder: %w", err)
|
|
||||||
}
|
|
||||||
destUID := uint32(destUIDNext)
|
|
||||||
|
|
||||||
var destModSeq int64
|
|
||||||
if err := tx.QueryRow(ctx, `UPDATE folders SET uid_next = uid_next + 1, highest_modseq = highest_modseq + 1, total_count = total_count + 1 WHERE id = $1 RETURNING highest_modseq`, destFolderID).Scan(&destModSeq); err != nil {
|
|
||||||
return 0, fmt.Errorf("advance destination uid_next and highest_modseq: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var wasSeen bool
|
|
||||||
if err := tx.QueryRow(ctx, `
|
|
||||||
SELECT EXISTS (SELECT 1 FROM message_flags WHERE message_id = $1 AND flag = '\Seen')
|
|
||||||
`, sourceMessageID).Scan(&wasSeen); err != nil {
|
|
||||||
return 0, fmt.Errorf("check source seen flag: %w", err)
|
|
||||||
}
|
|
||||||
if !wasSeen {
|
|
||||||
if _, err := tx.Exec(ctx, `UPDATE folders SET unread_count = unread_count + 1 WHERE id = $1`, destFolderID); err != nil {
|
|
||||||
return 0, fmt.Errorf("increment destination unread_count: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var destMailboxID string
|
|
||||||
if err := tx.QueryRow(ctx, `SELECT mailbox_id FROM folders WHERE id = $1`, destFolderID).Scan(&destMailboxID); err != nil {
|
|
||||||
return 0, fmt.Errorf("find destination mailbox: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var destMessageID string
|
|
||||||
var destReceivedAt any
|
|
||||||
err = tx.QueryRow(ctx, `
|
|
||||||
INSERT INTO email_messages (
|
|
||||||
mailbox_id, folder_id, uid, modseq, blob_id,
|
|
||||||
sender_address, recipient_addresses, size_bytes,
|
|
||||||
spf_result, dkim_result, dmarc_result,
|
|
||||||
from_display_name, subject, snippet, message_id_header, in_reply_to, thread_id, has_attachments,
|
|
||||||
arc_result, dane_status, tls_version, spam_score, spam_verdict, virus_name
|
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24)
|
|
||||||
RETURNING id, received_at
|
|
||||||
`, destMailboxID, destFolderID, destUID, destModSeq, blobID, senderAddress, recipientAddresses, sizeBytes, spfResult, dkimResult, dmarcResult,
|
|
||||||
fromDisplayName, subject, snippet, messageIDHeader, inReplyTo, threadID, hasAttachments,
|
|
||||||
arcResult, daneStatus, tlsVersion, spamScore, spamVerdict, virusName).Scan(&destMessageID, &destReceivedAt)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("insert copied message: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := tx.Exec(ctx, `UPDATE message_blobs SET ref_count = ref_count + 1 WHERE id = $1`, blobID); err != nil {
|
|
||||||
return 0, fmt.Errorf("increment blob ref_count: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := tx.Exec(ctx, `UPDATE mailboxes SET used_bytes = used_bytes + $1 WHERE id = $2`, sizeBytes, destMailboxID); err != nil {
|
|
||||||
return 0, fmt.Errorf("increment destination mailbox used_bytes: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := tx.Exec(ctx, `
|
|
||||||
INSERT INTO message_flags (message_id, received_at, flag)
|
|
||||||
SELECT $1, $2, flag FROM message_flags WHERE message_id = $3
|
|
||||||
`, destMessageID, destReceivedAt, sourceMessageID); err != nil {
|
|
||||||
return 0, fmt.Errorf("copy flags: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Commit(ctx); err != nil {
|
|
||||||
return 0, fmt.Errorf("commit: %w", err)
|
|
||||||
}
|
|
||||||
return destUID, nil
|
|
||||||
}
|
}
|
||||||
@@ -2,91 +2,21 @@ package postgres
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ExpungeRepository implements port.ExpungeRepository against Postgres.
|
// ExpungeRepository implements port.ExpungeRepository (IMAP EXPUNGE). MailStore
|
||||||
|
// does the work - counters, modseq and the quota all move there, for every path.
|
||||||
type ExpungeRepository struct {
|
type ExpungeRepository struct {
|
||||||
pool *pgxpool.Pool
|
store *MailStore
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewExpungeRepository(pool *pgxpool.Pool) *ExpungeRepository {
|
func NewExpungeRepository(pool *pgxpool.Pool) *ExpungeRepository {
|
||||||
return &ExpungeRepository{pool: pool}
|
return &ExpungeRepository{store: NewMailStore(pool)}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expunge marks each message identified by (folderID, uid) in uids as
|
|
||||||
// expunged, decrements the folder's total_count (and unread_count for any
|
|
||||||
// message that lacked \Seen), and decrements the owning mailbox's
|
|
||||||
// used_bytes by the message's size_bytes. used_bytes is logical,
|
|
||||||
// message-level accounting (mirroring how delivery and COPY add to it per
|
|
||||||
// message even when the underlying blob is deduplicated), so it is
|
|
||||||
// decremented here independently of message_blobs.ref_count, whose
|
|
||||||
// reclamation is deferred to a future GC task. One transaction per call;
|
|
||||||
// each uid is processed sequentially within it.
|
|
||||||
func (r *ExpungeRepository) Expunge(ctx context.Context, folderID string, uids []uint32) error {
|
func (r *ExpungeRepository) Expunge(ctx context.Context, folderID string, uids []uint32) error {
|
||||||
if len(uids) == 0 {
|
_, err := r.store.Expunge(ctx, folderID, uids)
|
||||||
return nil
|
return err
|
||||||
}
|
|
||||||
tx, err := r.pool.Begin(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("begin transaction: %w", err)
|
|
||||||
}
|
|
||||||
defer tx.Rollback(ctx)
|
|
||||||
|
|
||||||
for _, uid := range uids {
|
|
||||||
var wasSeen bool
|
|
||||||
var mailboxID string
|
|
||||||
var sizeBytes int64
|
|
||||||
err := tx.QueryRow(ctx, `
|
|
||||||
SELECT EXISTS (
|
|
||||||
SELECT 1 FROM message_flags mf
|
|
||||||
JOIN email_messages m ON m.id = mf.message_id AND m.received_at = mf.received_at
|
|
||||||
WHERE m.folder_id = $1 AND m.uid = $2 AND mf.flag = '\Seen'
|
|
||||||
), m.mailbox_id, m.size_bytes
|
|
||||||
FROM email_messages m
|
|
||||||
WHERE m.folder_id = $1 AND m.uid = $2
|
|
||||||
`, folderID, uid).Scan(&wasSeen, &mailboxID, &sizeBytes)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("check seen flag for uid %d: %w", uid, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tag, err := tx.Exec(ctx, `
|
|
||||||
UPDATE email_messages SET expunged_at = NOW()
|
|
||||||
WHERE folder_id = $1 AND uid = $2 AND expunged_at IS NULL
|
|
||||||
`, folderID, uid)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("expunge uid %d: %w", uid, err)
|
|
||||||
}
|
|
||||||
if tag.RowsAffected() == 0 {
|
|
||||||
// Nothing to expunge (already expunged or never existed) - the
|
|
||||||
// caller already verified \Deleted, so don't touch counters for
|
|
||||||
// a message that wasn't actually there.
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
unreadDelta := 0
|
|
||||||
if !wasSeen {
|
|
||||||
unreadDelta = 1
|
|
||||||
}
|
|
||||||
if _, err := tx.Exec(ctx, `
|
|
||||||
UPDATE folders SET total_count = total_count - 1, unread_count = unread_count - $1, highest_modseq = highest_modseq + 1
|
|
||||||
WHERE id = $2
|
|
||||||
`, unreadDelta, folderID); err != nil {
|
|
||||||
return fmt.Errorf("decrement folder counters for uid %d: %w", uid, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := tx.Exec(ctx, `
|
|
||||||
UPDATE mailboxes SET used_bytes = used_bytes - $1
|
|
||||||
WHERE id = $2
|
|
||||||
`, sizeBytes, mailboxID); err != nil {
|
|
||||||
return fmt.Errorf("decrement mailbox used_bytes for uid %d: %w", uid, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Commit(ctx); err != nil {
|
|
||||||
return fmt.Errorf("commit: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
@@ -2,95 +2,23 @@ package postgres
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
"lambdamail/protocols/internal/application/port"
|
"lambdamail/protocols/internal/application/port"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FlagRepository implements port.FlagRepository against Postgres.
|
// FlagRepository implements port.FlagRepository (IMAP STORE). The work is done
|
||||||
|
// by MailStore, which every path shares; this only names the message the way
|
||||||
|
// IMAP does, by folder and UID.
|
||||||
type FlagRepository struct {
|
type FlagRepository struct {
|
||||||
pool *pgxpool.Pool
|
store *MailStore
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewFlagRepository(pool *pgxpool.Pool) *FlagRepository {
|
func NewFlagRepository(pool *pgxpool.Pool) *FlagRepository {
|
||||||
return &FlagRepository{pool: pool}
|
return &FlagRepository{store: NewMailStore(pool)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *FlagRepository) SetFlags(ctx context.Context, folderID string, uid uint32, op port.FlagOp, flags []string, unchangedSince uint64) (bool, error) {
|
func (r *FlagRepository) SetFlags(ctx context.Context, folderID string, uid uint32, op port.FlagOp, flags []string, unchangedSince uint64) (bool, error) {
|
||||||
tx, err := r.pool.Begin(ctx)
|
return r.store.SetFlags(ctx, folderID, uid, op, flags, unchangedSince)
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf("begin transaction: %w", err)
|
|
||||||
}
|
|
||||||
defer tx.Rollback(ctx)
|
|
||||||
|
|
||||||
var currentModSeq uint64
|
|
||||||
if err := tx.QueryRow(ctx, `
|
|
||||||
SELECT modseq FROM email_messages WHERE folder_id = $1 AND uid = $2 AND expunged_at IS NULL
|
|
||||||
`, folderID, uid).Scan(¤tModSeq); err != nil {
|
|
||||||
return false, fmt.Errorf("find message folder=%s uid=%d: %w", folderID, uid, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if unchangedSince > 0 && currentModSeq > unchangedSince {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var newModSeq uint64
|
|
||||||
if err := tx.QueryRow(ctx, `
|
|
||||||
UPDATE folders SET highest_modseq = highest_modseq + 1 WHERE id = $1 RETURNING highest_modseq
|
|
||||||
`, folderID).Scan(&newModSeq); err != nil {
|
|
||||||
return false, fmt.Errorf("advance highest_modseq for folder %s: %w", folderID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := tx.Exec(ctx, `
|
|
||||||
UPDATE email_messages SET modseq = $1 WHERE folder_id = $2 AND uid = $3 AND expunged_at IS NULL
|
|
||||||
`, newModSeq, folderID, uid); err != nil {
|
|
||||||
return false, fmt.Errorf("update message modseq: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch op {
|
|
||||||
case port.FlagOpAdd:
|
|
||||||
for _, flag := range flags {
|
|
||||||
if _, err := tx.Exec(ctx, `
|
|
||||||
INSERT INTO message_flags (message_id, received_at, flag)
|
|
||||||
SELECT id, received_at, $3 FROM email_messages WHERE folder_id = $1 AND uid = $2 AND expunged_at IS NULL
|
|
||||||
ON CONFLICT DO NOTHING
|
|
||||||
`, folderID, uid, flag); err != nil {
|
|
||||||
return false, fmt.Errorf("add flag %q: %w", flag, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case port.FlagOpDel:
|
|
||||||
for _, flag := range flags {
|
|
||||||
if _, err := tx.Exec(ctx, `
|
|
||||||
DELETE FROM message_flags WHERE (message_id, received_at) IN (
|
|
||||||
SELECT id, received_at FROM email_messages WHERE folder_id = $1 AND uid = $2 AND expunged_at IS NULL
|
|
||||||
) AND flag = $3
|
|
||||||
`, folderID, uid, flag); err != nil {
|
|
||||||
return false, fmt.Errorf("delete flag %q: %w", flag, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case port.FlagOpSet:
|
|
||||||
if _, err := tx.Exec(ctx, `
|
|
||||||
DELETE FROM message_flags WHERE (message_id, received_at) IN (
|
|
||||||
SELECT id, received_at FROM email_messages WHERE folder_id = $1 AND uid = $2 AND expunged_at IS NULL
|
|
||||||
)
|
|
||||||
`, folderID, uid); err != nil {
|
|
||||||
return false, fmt.Errorf("clear flags before set: %w", err)
|
|
||||||
}
|
|
||||||
for _, flag := range flags {
|
|
||||||
if _, err := tx.Exec(ctx, `
|
|
||||||
INSERT INTO message_flags (message_id, received_at, flag)
|
|
||||||
SELECT id, received_at, $3 FROM email_messages WHERE folder_id = $1 AND uid = $2 AND expunged_at IS NULL
|
|
||||||
ON CONFLICT DO NOTHING
|
|
||||||
`, folderID, uid, flag); err != nil {
|
|
||||||
return false, fmt.Errorf("set flag %q: %w", flag, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Commit(ctx); err != nil {
|
|
||||||
return false, fmt.Errorf("commit: %w", err)
|
|
||||||
}
|
|
||||||
return true, nil
|
|
||||||
}
|
}
|
||||||
@@ -3,57 +3,50 @@ package postgres
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
"lambdamail/protocols/internal/application/port"
|
"lambdamail/protocols/internal/application/port"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ImapFolderRepository implements port.ImapFolderRepository against Postgres.
|
// ImapFolderRepository resolves a mailbox's folders for LIST and SELECT. The
|
||||||
|
// question is answered by MailStore, the same one the webmail asks, so a folder
|
||||||
|
// reports the same number of messages on both.
|
||||||
type ImapFolderRepository struct {
|
type ImapFolderRepository struct {
|
||||||
pool *pgxpool.Pool
|
store *MailStore
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewImapFolderRepository(pool *pgxpool.Pool) *ImapFolderRepository {
|
func NewImapFolderRepository(pool *pgxpool.Pool) *ImapFolderRepository {
|
||||||
return &ImapFolderRepository{pool: pool}
|
return &ImapFolderRepository{store: NewMailStore(pool)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func imapRecord(f FolderInfo) port.ImapFolderRecord {
|
||||||
|
return port.ImapFolderRecord{
|
||||||
|
ID: f.ID,
|
||||||
|
Name: f.Name,
|
||||||
|
UIDNext: uint32(f.UIDNext),
|
||||||
|
UIDValidity: uint32(f.UIDValidity),
|
||||||
|
NumMessages: uint32(f.Total),
|
||||||
|
HighestModSeq: uint64(f.HighestModSeq),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ImapFolderRepository) FindByName(ctx context.Context, mailboxID, name string) (*port.ImapFolderRecord, error) {
|
func (r *ImapFolderRepository) FindByName(ctx context.Context, mailboxID, name string) (*port.ImapFolderRecord, error) {
|
||||||
row := r.pool.QueryRow(ctx, `
|
f, err := r.store.FolderByName(ctx, mailboxID, name)
|
||||||
SELECT f.id, f.name, f.uid_next, f.uid_validity, f.total_count, f.highest_modseq
|
if err != nil || f == nil {
|
||||||
FROM folders f
|
|
||||||
WHERE f.mailbox_id = $1 AND f.name = $2
|
|
||||||
`, mailboxID, name)
|
|
||||||
|
|
||||||
var rec port.ImapFolderRecord
|
|
||||||
if err := row.Scan(&rec.ID, &rec.Name, &rec.UIDNext, &rec.UIDValidity, &rec.NumMessages, &rec.HighestModSeq); err != nil {
|
|
||||||
if err == pgx.ErrNoRows {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
rec := imapRecord(*f)
|
||||||
return &rec, nil
|
return &rec, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ImapFolderRepository) ListFolders(ctx context.Context, mailboxID string) ([]port.ImapFolderRecord, error) {
|
func (r *ImapFolderRepository) ListFolders(ctx context.Context, mailboxID string) ([]port.ImapFolderRecord, error) {
|
||||||
rows, err := r.pool.Query(ctx, `
|
folders, err := r.store.Folders(ctx, mailboxID)
|
||||||
SELECT f.id, f.name, f.uid_next, f.uid_validity, f.total_count, f.highest_modseq
|
|
||||||
FROM folders f
|
|
||||||
WHERE f.mailbox_id = $1
|
|
||||||
ORDER BY f.name
|
|
||||||
`, mailboxID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var out []port.ImapFolderRecord
|
var out []port.ImapFolderRecord
|
||||||
for rows.Next() {
|
for _, f := range folders {
|
||||||
var rec port.ImapFolderRecord
|
out = append(out, imapRecord(f))
|
||||||
if err := rows.Scan(&rec.ID, &rec.Name, &rec.UIDNext, &rec.UIDValidity, &rec.NumMessages, &rec.HighestModSeq); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out = append(out, rec)
|
|
||||||
}
|
}
|
||||||
return out, rows.Err()
|
return out, nil
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,534 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"lambdamail/protocols/internal/application/port"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The mailbox state contract.
|
||||||
|
//
|
||||||
|
// A message can be read, flagged, moved, copied and deleted from IMAP, from the
|
||||||
|
// webmail, and by the server itself. All of them change the same rows, and the
|
||||||
|
// rows carry cached numbers (folder counters, the folder's modseq, the
|
||||||
|
// mailbox's used bytes) that every path must keep true. Before there was one
|
||||||
|
// implementation, each path kept them true on its own, and they drifted:
|
||||||
|
//
|
||||||
|
// - reading a message over IMAP never moved the unread counter, so the badge
|
||||||
|
// in the webmail and IMAP STATUS disagreed with the flags;
|
||||||
|
// - deleting from the webmail never gave the quota back;
|
||||||
|
// - a second expunge of the same message could push a counter below zero.
|
||||||
|
//
|
||||||
|
// These tests state what must hold after any operation and run every operation
|
||||||
|
// through each path that can perform it. They do not care which code did the
|
||||||
|
// work, only that the result is the same.
|
||||||
|
|
||||||
|
// folderState is everything observable about one folder.
|
||||||
|
type folderState struct {
|
||||||
|
Name string
|
||||||
|
CachedTotal int
|
||||||
|
CachedUnread int
|
||||||
|
RealTotal int
|
||||||
|
RealUnread int
|
||||||
|
HighestModSeq int64
|
||||||
|
UIDNext int64
|
||||||
|
MaxMessageSeq int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// mailState reads the cached and the real numbers side by side.
|
||||||
|
func mailState(t *testing.T, pool *pgxpool.Pool, mailboxID string) (folders map[string]folderState, usedBytes, realBytes int64) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
rows, err := pool.Query(ctx, `
|
||||||
|
SELECT f.name, f.total_count, f.unread_count, f.highest_modseq, f.uid_next,
|
||||||
|
COUNT(e.id)::int,
|
||||||
|
COUNT(e.id) FILTER (WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM message_flags mf
|
||||||
|
WHERE mf.message_id = e.id AND mf.received_at = e.received_at AND mf.flag = '\Seen'))::int,
|
||||||
|
COALESCE(MAX(e.modseq), 0)
|
||||||
|
FROM folders f
|
||||||
|
LEFT JOIN email_messages e ON e.folder_id = f.id AND e.expunged_at IS NULL
|
||||||
|
WHERE f.mailbox_id = $1
|
||||||
|
GROUP BY f.id`, mailboxID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read folder state: %v", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
folders = map[string]folderState{}
|
||||||
|
for rows.Next() {
|
||||||
|
var s folderState
|
||||||
|
if err := rows.Scan(&s.Name, &s.CachedTotal, &s.CachedUnread, &s.HighestModSeq, &s.UIDNext, &s.RealTotal, &s.RealUnread, &s.MaxMessageSeq); err != nil {
|
||||||
|
t.Fatalf("scan folder state: %v", err)
|
||||||
|
}
|
||||||
|
folders[s.Name] = s
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := pool.QueryRow(ctx, `SELECT used_bytes FROM mailboxes WHERE id = $1`, mailboxID).Scan(&usedBytes); err != nil {
|
||||||
|
t.Fatalf("read used_bytes: %v", err)
|
||||||
|
}
|
||||||
|
if err := pool.QueryRow(ctx, `SELECT COALESCE(SUM(size_bytes), 0) FROM email_messages WHERE mailbox_id = $1 AND expunged_at IS NULL`, mailboxID).Scan(&realBytes); err != nil {
|
||||||
|
t.Fatalf("read real bytes: %v", err)
|
||||||
|
}
|
||||||
|
return folders, usedBytes, realBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertConsistent is the contract: every cached number equals what the rows say.
|
||||||
|
func assertConsistent(t *testing.T, pool *pgxpool.Pool, mailboxID, after string) {
|
||||||
|
t.Helper()
|
||||||
|
folders, used, real := mailState(t, pool, mailboxID)
|
||||||
|
|
||||||
|
for name, f := range folders {
|
||||||
|
if f.CachedTotal != f.RealTotal {
|
||||||
|
t.Errorf("after %s: %s total_count = %d, but it holds %d messages", after, name, f.CachedTotal, f.RealTotal)
|
||||||
|
}
|
||||||
|
if f.CachedUnread != f.RealUnread {
|
||||||
|
t.Errorf("after %s: %s unread_count = %d, but %d messages are unread", after, name, f.CachedUnread, f.RealUnread)
|
||||||
|
}
|
||||||
|
if f.CachedUnread < 0 || f.CachedTotal < 0 {
|
||||||
|
t.Errorf("after %s: %s has a negative counter (unread %d, total %d)", after, name, f.CachedUnread, f.CachedTotal)
|
||||||
|
}
|
||||||
|
if f.HighestModSeq < f.MaxMessageSeq {
|
||||||
|
t.Errorf("after %s: %s highest_modseq %d is behind a message's modseq %d, so a CONDSTORE client would miss the change", after, name, f.HighestModSeq, f.MaxMessageSeq)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if used != real {
|
||||||
|
t.Errorf("after %s: mailbox used_bytes = %d, but its messages add up to %d", after, used, real)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// modseqOf is the folder's highest_modseq, which every observable change must raise.
|
||||||
|
func modseqOf(t *testing.T, pool *pgxpool.Pool, mailboxID, folder string) int64 {
|
||||||
|
t.Helper()
|
||||||
|
folders, _, _ := mailState(t, pool, mailboxID)
|
||||||
|
return folders[folder].HighestModSeq
|
||||||
|
}
|
||||||
|
|
||||||
|
func folderID(t *testing.T, pool *pgxpool.Pool, mailboxID, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
var id string
|
||||||
|
if err := pool.QueryRow(context.Background(), `SELECT id::text FROM folders WHERE mailbox_id = $1 AND name = $2`, mailboxID, name).Scan(&id); err != nil {
|
||||||
|
t.Fatalf("find folder %s: %v", name, err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// path is one way of doing each thing to a message. IMAP and webmail both
|
||||||
|
// implement it; the contract runs against each.
|
||||||
|
type path struct {
|
||||||
|
name string
|
||||||
|
setSeen func(t *testing.T, pool *pgxpool.Pool, mailboxID, folder string, uid uint32, seen bool)
|
||||||
|
setFlag func(t *testing.T, pool *pgxpool.Pool, mailboxID, folder string, uid uint32, flagged bool)
|
||||||
|
expunge func(t *testing.T, pool *pgxpool.Pool, mailboxID, folder string, uid uint32)
|
||||||
|
moveTo func(t *testing.T, pool *pgxpool.Pool, mailboxID, folder string, uid uint32, target string)
|
||||||
|
flagsKept func() bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func imapPath() path {
|
||||||
|
return path{
|
||||||
|
name: "imap",
|
||||||
|
setSeen: func(t *testing.T, pool *pgxpool.Pool, mailboxID, folder string, uid uint32, seen bool) {
|
||||||
|
op := port.FlagOpAdd
|
||||||
|
if !seen {
|
||||||
|
op = port.FlagOpDel
|
||||||
|
}
|
||||||
|
if _, err := NewFlagRepository(pool).SetFlags(context.Background(), folderID(t, pool, mailboxID, folder), uid, op, []string{`\Seen`}, 0); err != nil {
|
||||||
|
t.Fatalf("imap SetFlags: %v", err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setFlag: func(t *testing.T, pool *pgxpool.Pool, mailboxID, folder string, uid uint32, flagged bool) {
|
||||||
|
op := port.FlagOpAdd
|
||||||
|
if !flagged {
|
||||||
|
op = port.FlagOpDel
|
||||||
|
}
|
||||||
|
if _, err := NewFlagRepository(pool).SetFlags(context.Background(), folderID(t, pool, mailboxID, folder), uid, op, []string{`\Flagged`}, 0); err != nil {
|
||||||
|
t.Fatalf("imap SetFlags: %v", err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
expunge: func(t *testing.T, pool *pgxpool.Pool, mailboxID, folder string, uid uint32) {
|
||||||
|
if err := NewExpungeRepository(pool).Expunge(context.Background(), folderID(t, pool, mailboxID, folder), []uint32{uid}); err != nil {
|
||||||
|
t.Fatalf("imap Expunge: %v", err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
moveTo: func(t *testing.T, pool *pgxpool.Pool, mailboxID, folder string, uid uint32, target string) {
|
||||||
|
// IMAP has no MOVE here: a client copies, marks \Deleted and expunges
|
||||||
|
src, dst := folderID(t, pool, mailboxID, folder), folderID(t, pool, mailboxID, target)
|
||||||
|
if _, err := NewCopyRepository(pool).CopyMessages(context.Background(), src, []uint32{uid}, dst); err != nil {
|
||||||
|
t.Fatalf("imap Copy: %v", err)
|
||||||
|
}
|
||||||
|
if err := NewExpungeRepository(pool).Expunge(context.Background(), src, []uint32{uid}); err != nil {
|
||||||
|
t.Fatalf("imap Expunge after copy: %v", err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func webmailPath() path {
|
||||||
|
return path{
|
||||||
|
name: "webmail",
|
||||||
|
setSeen: func(t *testing.T, pool *pgxpool.Pool, mailboxID, folder string, uid uint32, seen bool) {
|
||||||
|
if err := NewWebmailRepository(pool).MarkSeen(context.Background(), mailboxID, folder, uid, seen); err != nil {
|
||||||
|
t.Fatalf("webmail MarkSeen: %v", err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setFlag: func(t *testing.T, pool *pgxpool.Pool, mailboxID, folder string, uid uint32, flagged bool) {
|
||||||
|
if err := NewWebmailRepository(pool).MarkFlagged(context.Background(), mailboxID, folder, uid, flagged); err != nil {
|
||||||
|
t.Fatalf("webmail MarkFlagged: %v", err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
expunge: func(t *testing.T, pool *pgxpool.Pool, mailboxID, folder string, uid uint32) {
|
||||||
|
if err := NewWebmailRepository(pool).Expunge(context.Background(), mailboxID, folder, uid); err != nil {
|
||||||
|
t.Fatalf("webmail Expunge: %v", err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
moveTo: func(t *testing.T, pool *pgxpool.Pool, mailboxID, folder string, uid uint32, target string) {
|
||||||
|
if _, err := NewWebmailRepository(pool).MoveToFolder(context.Background(), mailboxID, folder, uid, target); err != nil {
|
||||||
|
t.Fatalf("webmail Move: %v", err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func eachPath(t *testing.T, scenario func(t *testing.T, p path)) {
|
||||||
|
for _, p := range []path{imapPath(), webmailPath()} {
|
||||||
|
p := p
|
||||||
|
t.Run(p.name, func(t *testing.T) { scenario(t, p) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContract_ReadingAMessageMovesTheUnreadCounter(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
eachPath(t, func(t *testing.T, p path) {
|
||||||
|
mailboxID := seedMailbox(t, pool)
|
||||||
|
a := deliver(t, pool, mailboxID, "INBOX", "a", false)
|
||||||
|
deliver(t, pool, mailboxID, "INBOX", "b", false)
|
||||||
|
assertConsistent(t, pool, mailboxID, "delivery")
|
||||||
|
|
||||||
|
before := modseqOf(t, pool, mailboxID, "INBOX")
|
||||||
|
p.setSeen(t, pool, mailboxID, "INBOX", a, true)
|
||||||
|
assertConsistent(t, pool, mailboxID, "marking one message read")
|
||||||
|
if after := modseqOf(t, pool, mailboxID, "INBOX"); after <= before {
|
||||||
|
t.Errorf("marking read left highest_modseq at %d (was %d): clients holding the folder never learn of it", after, before)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.setSeen(t, pool, mailboxID, "INBOX", a, true)
|
||||||
|
assertConsistent(t, pool, mailboxID, "marking the same message read twice")
|
||||||
|
|
||||||
|
p.setSeen(t, pool, mailboxID, "INBOX", a, false)
|
||||||
|
assertConsistent(t, pool, mailboxID, "marking it unread again")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContract_FlaggingBumpsTheModseqAndNothingElse(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
eachPath(t, func(t *testing.T, p path) {
|
||||||
|
mailboxID := seedMailbox(t, pool)
|
||||||
|
a := deliver(t, pool, mailboxID, "INBOX", "a", false)
|
||||||
|
|
||||||
|
before := modseqOf(t, pool, mailboxID, "INBOX")
|
||||||
|
p.setFlag(t, pool, mailboxID, "INBOX", a, true)
|
||||||
|
assertConsistent(t, pool, mailboxID, "flagging")
|
||||||
|
if after := modseqOf(t, pool, mailboxID, "INBOX"); after <= before {
|
||||||
|
t.Errorf("flagging left highest_modseq at %d (was %d)", after, before)
|
||||||
|
}
|
||||||
|
p.setFlag(t, pool, mailboxID, "INBOX", a, false)
|
||||||
|
assertConsistent(t, pool, mailboxID, "unflagging")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContract_DeletingFreesTheQuotaAndTheCounters(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
eachPath(t, func(t *testing.T, p path) {
|
||||||
|
mailboxID := seedMailbox(t, pool)
|
||||||
|
unread := deliver(t, pool, mailboxID, "INBOX", "unread", false)
|
||||||
|
read := deliver(t, pool, mailboxID, "INBOX", "read", true)
|
||||||
|
assertConsistent(t, pool, mailboxID, "delivery")
|
||||||
|
|
||||||
|
p.expunge(t, pool, mailboxID, "INBOX", unread)
|
||||||
|
assertConsistent(t, pool, mailboxID, "deleting an unread message")
|
||||||
|
|
||||||
|
p.expunge(t, pool, mailboxID, "INBOX", read)
|
||||||
|
assertConsistent(t, pool, mailboxID, "deleting a read message")
|
||||||
|
|
||||||
|
_, used, _ := mailState(t, pool, mailboxID)
|
||||||
|
if used != 0 {
|
||||||
|
t.Errorf("an emptied mailbox still reports %d used bytes", used)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContract_AMessageAlreadyDeletedCannotDriveACounterNegative(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
eachPath(t, func(t *testing.T, p path) {
|
||||||
|
mailboxID := seedMailbox(t, pool)
|
||||||
|
a := deliver(t, pool, mailboxID, "INBOX", "a", false)
|
||||||
|
deliver(t, pool, mailboxID, "INBOX", "b", false)
|
||||||
|
|
||||||
|
p.expunge(t, pool, mailboxID, "INBOX", a)
|
||||||
|
// The second one is the same message again. The webmail reports it as
|
||||||
|
// not found; IMAP ignores it. Either way no number may move.
|
||||||
|
func() {
|
||||||
|
defer func() { _ = recover() }()
|
||||||
|
expungeAgain(t, pool, p, mailboxID, a)
|
||||||
|
}()
|
||||||
|
assertConsistent(t, pool, mailboxID, "deleting the same message twice")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// expungeAgain tolerates the webmail's "not found" while still letting the
|
||||||
|
// contract check the counters afterwards.
|
||||||
|
func expungeAgain(t *testing.T, pool *pgxpool.Pool, p path, mailboxID string, uid uint32) {
|
||||||
|
t.Helper()
|
||||||
|
switch p.name {
|
||||||
|
case "imap":
|
||||||
|
_ = NewExpungeRepository(pool).Expunge(context.Background(), folderID(t, pool, mailboxID, "INBOX"), []uint32{uid})
|
||||||
|
default:
|
||||||
|
_ = NewWebmailRepository(pool).Expunge(context.Background(), mailboxID, "INBOX", uid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContract_MovingAMessageKeepsBothFoldersTrue(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
eachPath(t, func(t *testing.T, p path) {
|
||||||
|
mailboxID := seedMailbox(t, pool)
|
||||||
|
unread := deliver(t, pool, mailboxID, "INBOX", "unread", false)
|
||||||
|
read := deliver(t, pool, mailboxID, "INBOX", "read", true)
|
||||||
|
|
||||||
|
p.moveTo(t, pool, mailboxID, "INBOX", unread, "Trash")
|
||||||
|
assertConsistent(t, pool, mailboxID, "moving an unread message")
|
||||||
|
p.moveTo(t, pool, mailboxID, "INBOX", read, "Trash")
|
||||||
|
assertConsistent(t, pool, mailboxID, "moving a read message")
|
||||||
|
|
||||||
|
folders, _, _ := mailState(t, pool, mailboxID)
|
||||||
|
if folders["INBOX"].RealTotal != 0 || folders["Trash"].RealTotal != 2 {
|
||||||
|
t.Errorf("INBOX holds %d and Trash %d, want 0 and 2", folders["INBOX"].RealTotal, folders["Trash"].RealTotal)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reading mail over IMAP and then looking at it in the webmail (or the other way
|
||||||
|
// round) is the everyday case: the two views must agree after every step.
|
||||||
|
func TestContract_ImapAndWebmailAgreeOnTheSameMailbox(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
imap, web := imapPath(), webmailPath()
|
||||||
|
|
||||||
|
mailboxID := seedMailbox(t, pool)
|
||||||
|
var uids []uint32
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
uids = append(uids, deliver(t, pool, mailboxID, "INBOX", fmt.Sprintf("m%d", i), false))
|
||||||
|
}
|
||||||
|
|
||||||
|
imap.setSeen(t, pool, mailboxID, "INBOX", uids[0], true)
|
||||||
|
assertConsistent(t, pool, mailboxID, "reading m0 over IMAP")
|
||||||
|
web.setSeen(t, pool, mailboxID, "INBOX", uids[1], true)
|
||||||
|
assertConsistent(t, pool, mailboxID, "reading m1 in the webmail")
|
||||||
|
imap.setFlag(t, pool, mailboxID, "INBOX", uids[2], true)
|
||||||
|
web.expunge(t, pool, mailboxID, "INBOX", uids[3])
|
||||||
|
assertConsistent(t, pool, mailboxID, "deleting m3 in the webmail")
|
||||||
|
imap.expunge(t, pool, mailboxID, "INBOX", uids[1])
|
||||||
|
assertConsistent(t, pool, mailboxID, "deleting the already-read m1 over IMAP")
|
||||||
|
web.moveTo(t, pool, mailboxID, "INBOX", uids[4], "Trash")
|
||||||
|
assertConsistent(t, pool, mailboxID, "moving m4 to Trash in the webmail")
|
||||||
|
imap.moveTo(t, pool, mailboxID, "INBOX", uids[2], "Trash")
|
||||||
|
assertConsistent(t, pool, mailboxID, "moving m2 to Trash over IMAP")
|
||||||
|
|
||||||
|
// What the webmail reports for a folder is what IMAP would answer
|
||||||
|
ctx := context.Background()
|
||||||
|
wmFolders, err := NewWebmailRepository(pool).ListFolders(ctx, mailboxID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("webmail ListFolders: %v", err)
|
||||||
|
}
|
||||||
|
imFolders, err := NewImapFolderRepository(pool).ListFolders(ctx, mailboxID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("imap ListFolders: %v", err)
|
||||||
|
}
|
||||||
|
imapTotals := map[string]int{}
|
||||||
|
for _, f := range imFolders {
|
||||||
|
imapTotals[f.Name] = int(f.NumMessages)
|
||||||
|
}
|
||||||
|
for _, f := range wmFolders {
|
||||||
|
if got := imapTotals[f.Name]; got != f.TotalCount {
|
||||||
|
t.Errorf("%s: the webmail shows %d messages and IMAP says %d", f.Name, f.TotalCount, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A folder deleted by its owner takes its messages with it, and their bytes.
|
||||||
|
func TestContract_DeletingAFolderReturnsItsBytes(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
mailboxID := seedMailbox(t, pool)
|
||||||
|
repo := NewWebmailRepository(pool)
|
||||||
|
|
||||||
|
if err := repo.CreateFolder(ctx, mailboxID, "Projetos"); err != nil {
|
||||||
|
t.Fatalf("create folder: %v", err)
|
||||||
|
}
|
||||||
|
deliver(t, pool, mailboxID, "Projetos", "one", false)
|
||||||
|
deliver(t, pool, mailboxID, "Projetos", "two", true)
|
||||||
|
deliver(t, pool, mailboxID, "INBOX", "stays", false)
|
||||||
|
assertConsistent(t, pool, mailboxID, "filing into a new folder")
|
||||||
|
|
||||||
|
if err := repo.DeleteFolder(ctx, mailboxID, "Projetos"); err != nil {
|
||||||
|
t.Fatalf("delete folder: %v", err)
|
||||||
|
}
|
||||||
|
assertConsistent(t, pool, mailboxID, "deleting the folder")
|
||||||
|
}
|
||||||
|
|
||||||
|
// IMAP's STORE can replace a message's whole flag set; the unread counter has to
|
||||||
|
// follow the \Seen that comes or goes with it.
|
||||||
|
func TestContract_ReplacingTheFlagSetKeepsTheUnreadCounterTrue(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
mailboxID := seedMailbox(t, pool)
|
||||||
|
a := deliver(t, pool, mailboxID, "INBOX", "a", false)
|
||||||
|
b := deliver(t, pool, mailboxID, "INBOX", "b", true)
|
||||||
|
inbox := folderID(t, pool, mailboxID, "INBOX")
|
||||||
|
repo := NewFlagRepository(pool)
|
||||||
|
|
||||||
|
if _, err := repo.SetFlags(ctx, inbox, a, port.FlagOpSet, []string{`\Seen`, `\Flagged`}, 0); err != nil {
|
||||||
|
t.Fatalf("SetFlags: %v", err)
|
||||||
|
}
|
||||||
|
assertConsistent(t, pool, mailboxID, "replacing flags with one that includes \\Seen")
|
||||||
|
|
||||||
|
if _, err := repo.SetFlags(ctx, inbox, b, port.FlagOpSet, []string{`\Flagged`}, 0); err != nil {
|
||||||
|
t.Fatalf("SetFlags: %v", err)
|
||||||
|
}
|
||||||
|
assertConsistent(t, pool, mailboxID, "replacing flags with one that drops \\Seen")
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = uuid.Nil
|
||||||
|
|
||||||
|
// IMAP clients, the webmail and the delivery path all touch a mailbox at the same
|
||||||
|
// moment. The counters are shared rows, so they have to stay true under that.
|
||||||
|
func TestContract_ConcurrentChangesFromBothPathsKeepTheCountersTrue(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
mailboxID := seedMailbox(t, pool)
|
||||||
|
|
||||||
|
const n = 24
|
||||||
|
uids := make([]uint32, 0, n)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
uids = append(uids, deliver(t, pool, mailboxID, "INBOX", fmt.Sprintf("m%d", i), false))
|
||||||
|
}
|
||||||
|
|
||||||
|
imap, web := imapPath(), webmailPath()
|
||||||
|
done := make(chan struct{}, n)
|
||||||
|
for i, uid := range uids {
|
||||||
|
go func(i int, uid uint32) {
|
||||||
|
defer func() { done <- struct{}{} }()
|
||||||
|
switch i % 3 {
|
||||||
|
case 0:
|
||||||
|
imap.setSeen(t, pool, mailboxID, "INBOX", uid, true)
|
||||||
|
case 1:
|
||||||
|
web.setSeen(t, pool, mailboxID, "INBOX", uid, true)
|
||||||
|
default:
|
||||||
|
if i%2 == 0 {
|
||||||
|
imap.expunge(t, pool, mailboxID, "INBOX", uid)
|
||||||
|
} else {
|
||||||
|
web.expunge(t, pool, mailboxID, "INBOX", uid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(i, uid)
|
||||||
|
}
|
||||||
|
for range uids {
|
||||||
|
<-done
|
||||||
|
}
|
||||||
|
|
||||||
|
assertConsistent(t, pool, mailboxID, "24 concurrent changes from IMAP and the webmail")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two clients deleting the same message at once is the realistic race (the
|
||||||
|
// webmail and a phone both open on the same mail). It is removed once, and every
|
||||||
|
// number moves once.
|
||||||
|
func TestContract_DeletingTheSameMessageConcurrentlyCountsOnce(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
mailboxID := seedMailbox(t, pool)
|
||||||
|
target := deliver(t, pool, mailboxID, "INBOX", "contested", false)
|
||||||
|
deliver(t, pool, mailboxID, "INBOX", "bystander", false)
|
||||||
|
inbox := folderID(t, pool, mailboxID, "INBOX")
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
store := NewMailStore(pool)
|
||||||
|
removed := make(chan int, 12)
|
||||||
|
for i := 0; i < 12; i++ {
|
||||||
|
go func() {
|
||||||
|
n, err := store.Expunge(ctx, inbox, []uint32{target})
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("expunge: %v", err)
|
||||||
|
}
|
||||||
|
removed <- n
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
total := 0
|
||||||
|
for i := 0; i < 12; i++ {
|
||||||
|
total += <-removed
|
||||||
|
}
|
||||||
|
|
||||||
|
if total != 1 {
|
||||||
|
t.Errorf("12 concurrent expunges of one message removed it %d times, want exactly once", total)
|
||||||
|
}
|
||||||
|
assertConsistent(t, pool, mailboxID, "12 clients deleting the same message")
|
||||||
|
folders, used, _ := mailState(t, pool, mailboxID)
|
||||||
|
if folders["INBOX"].RealTotal != 1 || used != 10 {
|
||||||
|
t.Errorf("INBOX holds %d and the mailbox %d bytes, want 1 message and 10 bytes", folders["INBOX"].RealTotal, used)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Moves in opposite directions, flag changes and deletes, all at once on the same
|
||||||
|
// two folders. Locking the folders and the message in different orders in
|
||||||
|
// different operations deadlocks here; a fixed order does not.
|
||||||
|
func TestContract_OppositeMovesAndFlagChangesNeverDeadlock(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
mailboxID := seedMailbox(t, pool)
|
||||||
|
|
||||||
|
const each = 20
|
||||||
|
var inboxUIDs, trashUIDs []uint32
|
||||||
|
for i := 0; i < each; i++ {
|
||||||
|
inboxUIDs = append(inboxUIDs, deliver(t, pool, mailboxID, "INBOX", fmt.Sprintf("i%d", i), false))
|
||||||
|
trashUIDs = append(trashUIDs, deliver(t, pool, mailboxID, "Trash", fmt.Sprintf("t%d", i), false))
|
||||||
|
}
|
||||||
|
|
||||||
|
inbox, trash := folderID(t, pool, mailboxID, "INBOX"), folderID(t, pool, mailboxID, "Trash")
|
||||||
|
store := NewMailStore(pool)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
errs := make(chan error, each*4)
|
||||||
|
var pending int
|
||||||
|
run := func(f func() error) {
|
||||||
|
pending++
|
||||||
|
go func() { errs <- f() }()
|
||||||
|
}
|
||||||
|
for i := 0; i < each; i++ {
|
||||||
|
i := i
|
||||||
|
run(func() error { _, err := store.Move(ctx, inbox, inboxUIDs[i], trash); return nil2(err) })
|
||||||
|
run(func() error { _, err := store.Move(ctx, trash, trashUIDs[i], inbox); return nil2(err) })
|
||||||
|
// a flag change on a message that is being moved elsewhere, and a delete
|
||||||
|
run(func() error {
|
||||||
|
_, err := store.SetFlags(ctx, inbox, inboxUIDs[(i+1)%each], port.FlagOpAdd, []string{`\Flagged`}, 0)
|
||||||
|
return nil2(err)
|
||||||
|
})
|
||||||
|
run(func() error { _, err := store.Expunge(ctx, trash, []uint32{trashUIDs[(i+2)%each]}); return nil2(err) })
|
||||||
|
}
|
||||||
|
for ; pending > 0; pending-- {
|
||||||
|
if err := <-errs; err != nil {
|
||||||
|
t.Errorf("a concurrent operation failed (a deadlock, if it says 40P01): %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertConsistent(t, pool, mailboxID, "opposite moves, flag changes and deletes all at once")
|
||||||
|
}
|
||||||
|
|
||||||
|
// nil2 lets the contract tolerate "the message was moved or deleted first", a
|
||||||
|
// legitimate outcome of a race, while still surfacing every other error (above
|
||||||
|
// all a deadlock, SQLSTATE 40P01).
|
||||||
|
func nil2(err error) error {
|
||||||
|
if err == ErrMessageNotFound {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"lambdamail/protocols/internal/application/port"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MailStore is the one place that changes the state of messages and folders.
|
||||||
|
//
|
||||||
|
// IMAP, POP3, the webmail and the server's own filing all end up changing the
|
||||||
|
// same rows, and those rows carry cached numbers that have to stay true: the
|
||||||
|
// folder's unread and total counters, its highest modseq (what a CONDSTORE
|
||||||
|
// client uses to learn that something changed), the next UID, and the
|
||||||
|
// mailbox's used bytes. When each path implemented this on its own they
|
||||||
|
// drifted apart - reading over IMAP never moved the unread counter, deleting in
|
||||||
|
// the webmail never gave the quota back, a repeated expunge could push a
|
||||||
|
// counter below zero - and every fix had to be made twice, which is how one of
|
||||||
|
// the two went missing.
|
||||||
|
//
|
||||||
|
// So each state change is written here, once, in one transaction, and every
|
||||||
|
// path calls it. The repositories IMAP and the webmail used are now thin
|
||||||
|
// adapters over this: they translate their way of naming things (a folder id
|
||||||
|
// and a UID, or a mailbox, a folder name and a UID) and nothing else.
|
||||||
|
//
|
||||||
|
// Rules held for every method:
|
||||||
|
//
|
||||||
|
// - A counter never goes below zero, so a double operation cannot corrupt it.
|
||||||
|
// - An operation that changes what a client can observe raises the folder's
|
||||||
|
// modseq; one that changes nothing does not.
|
||||||
|
// - A message is located by folder, so another mailbox's mail cannot be
|
||||||
|
// reached by guessing a UID.
|
||||||
|
type MailStore struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMailStore(pool *pgxpool.Pool) *MailStore {
|
||||||
|
return &MailStore{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
// lockFolders takes the row lock of each folder, in a fixed order.
|
||||||
|
//
|
||||||
|
// Every change updates counters on a folder row, and a move updates two. If one
|
||||||
|
// operation locked the destination folder and then the source, and another did
|
||||||
|
// the reverse, two moves in opposite directions deadlocked, and so did a move
|
||||||
|
// racing a flag change (it held the message and wanted the folder, the move held
|
||||||
|
// the folder and wanted the message). So the order is the same everywhere:
|
||||||
|
// folders first, lowest id first, then the message.
|
||||||
|
func lockFolders(ctx context.Context, tx pgx.Tx, ids ...string) error {
|
||||||
|
sort.Strings(ids)
|
||||||
|
for i, id := range ids {
|
||||||
|
if i > 0 && id == ids[i-1] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `SELECT 1 FROM folders WHERE id = $1 FOR UPDATE`, id); err != nil {
|
||||||
|
return fmt.Errorf("lock folder %s: %w", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// messageRow is the slice of a message every operation needs.
|
||||||
|
type messageRow struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
ReceivedAt any
|
||||||
|
MailboxID string
|
||||||
|
Size int64
|
||||||
|
ModSeq uint64
|
||||||
|
Seen bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadMessage finds a live message by folder and UID and locks it, so two
|
||||||
|
// concurrent changes to the same message cannot both read the old state.
|
||||||
|
func loadMessage(ctx context.Context, tx pgx.Tx, folderID string, uid uint32) (*messageRow, error) {
|
||||||
|
var m messageRow
|
||||||
|
err := tx.QueryRow(ctx, `
|
||||||
|
SELECT e.id, e.received_at, e.mailbox_id::text, e.size_bytes, e.modseq,
|
||||||
|
EXISTS (SELECT 1 FROM message_flags mf
|
||||||
|
WHERE mf.message_id = e.id AND mf.received_at = e.received_at AND mf.flag = '\Seen')
|
||||||
|
FROM email_messages e
|
||||||
|
WHERE e.folder_id = $1 AND e.uid = $2 AND e.expunged_at IS NULL
|
||||||
|
FOR UPDATE
|
||||||
|
`, folderID, int64(uid)).Scan(&m.ID, &m.ReceivedAt, &m.MailboxID, &m.Size, &m.ModSeq, &m.Seen)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrMessageNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("find message folder=%s uid=%d: %w", folderID, uid, err)
|
||||||
|
}
|
||||||
|
return &m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveFolder finds a folder of a mailbox by its special-use role or by its
|
||||||
|
// name, the way the webmail and IMAP clients both address one ("trash" and
|
||||||
|
// "Trash" reach the same place; a folder the user made has no role and is
|
||||||
|
// reached by name). port.ErrFolderMissing when there is none.
|
||||||
|
func (s *MailStore) ResolveFolder(ctx context.Context, mailboxID, ref string) (string, error) {
|
||||||
|
var id string
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT id::text FROM folders
|
||||||
|
WHERE mailbox_id = $1 AND (special_use = LOWER($2) OR LOWER(name) = LOWER($2))
|
||||||
|
ORDER BY special_use IS NOT NULL DESC
|
||||||
|
LIMIT 1
|
||||||
|
`, mailboxID, ref).Scan(&id)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return "", port.ErrFolderMissing
|
||||||
|
}
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetFlags applies op to one message's flags. Add and Del touch individual
|
||||||
|
// flags, Set replaces the whole set. If unchangedSince > 0 and the message has
|
||||||
|
// changed since, nothing is written and updated is false (RFC 7162
|
||||||
|
// UNCHANGEDSINCE).
|
||||||
|
//
|
||||||
|
// \Seen is the flag with a side effect: the folder's unread counter follows it,
|
||||||
|
// in the same transaction, by looking at the flag before and after rather than
|
||||||
|
// at which operation was asked for. That is what keeps STORE +FLAGS, STORE
|
||||||
|
// -FLAGS, STORE FLAGS (replace) and the webmail's mark-as-read all counting the
|
||||||
|
// same way.
|
||||||
|
func (s *MailStore) SetFlags(ctx context.Context, folderID string, uid uint32, op port.FlagOp, flags []string, unchangedSince uint64) (bool, error) {
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("begin transaction: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
if err := lockFolders(ctx, tx, folderID); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
msg, err := loadMessage(ctx, tx, folderID, uid)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if unchangedSince > 0 && msg.ModSeq > unchangedSince {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
before, err := flagSet(ctx, tx, msg)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch op {
|
||||||
|
case port.FlagOpAdd:
|
||||||
|
for _, flag := range flags {
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO message_flags (message_id, received_at, flag) VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT DO NOTHING`, msg.ID, msg.ReceivedAt, flag); err != nil {
|
||||||
|
return false, fmt.Errorf("add flag %q: %w", flag, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case port.FlagOpDel:
|
||||||
|
for _, flag := range flags {
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
DELETE FROM message_flags WHERE message_id = $1 AND received_at = $2 AND flag = $3`,
|
||||||
|
msg.ID, msg.ReceivedAt, flag); err != nil {
|
||||||
|
return false, fmt.Errorf("delete flag %q: %w", flag, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case port.FlagOpSet:
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
DELETE FROM message_flags WHERE message_id = $1 AND received_at = $2`, msg.ID, msg.ReceivedAt); err != nil {
|
||||||
|
return false, fmt.Errorf("clear flags before set: %w", err)
|
||||||
|
}
|
||||||
|
for _, flag := range flags {
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO message_flags (message_id, received_at, flag) VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT DO NOTHING`, msg.ID, msg.ReceivedAt, flag); err != nil {
|
||||||
|
return false, fmt.Errorf("set flag %q: %w", flag, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
after, err := flagSet(ctx, tx, msg)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing changed: no modseq, no counter. A client that stores a flag the
|
||||||
|
// message already has must not be told the folder changed.
|
||||||
|
if sameFlags(before, after) {
|
||||||
|
return true, tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
unreadDelta := 0
|
||||||
|
switch {
|
||||||
|
case before[`\Seen`] && !after[`\Seen`]:
|
||||||
|
unreadDelta = 1
|
||||||
|
case !before[`\Seen`] && after[`\Seen`]:
|
||||||
|
unreadDelta = -1
|
||||||
|
}
|
||||||
|
|
||||||
|
var modseq int64
|
||||||
|
if err := tx.QueryRow(ctx, `
|
||||||
|
UPDATE folders
|
||||||
|
SET highest_modseq = highest_modseq + 1,
|
||||||
|
unread_count = GREATEST(0, unread_count + $2)
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING highest_modseq`, folderID, unreadDelta).Scan(&modseq); err != nil {
|
||||||
|
return false, fmt.Errorf("advance folder modseq: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `UPDATE email_messages SET modseq = $3 WHERE id = $1 AND received_at = $2`,
|
||||||
|
msg.ID, msg.ReceivedAt, modseq); err != nil {
|
||||||
|
return false, fmt.Errorf("update message modseq: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func flagSet(ctx context.Context, tx pgx.Tx, msg *messageRow) (map[string]bool, error) {
|
||||||
|
rows, err := tx.Query(ctx, `SELECT flag FROM message_flags WHERE message_id = $1 AND received_at = $2`, msg.ID, msg.ReceivedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read flags: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
set := map[string]bool{}
|
||||||
|
for rows.Next() {
|
||||||
|
var f string
|
||||||
|
if err := rows.Scan(&f); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
set[f] = true
|
||||||
|
}
|
||||||
|
return set, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameFlags(a, b map[string]bool) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for k := range a {
|
||||||
|
if !b[k] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expunge removes messages from a folder (soft delete: the row stays, marked,
|
||||||
|
// and the blob stays referenced until the retention sweep, so a mistake is
|
||||||
|
// recoverable and a client holding the UID still resolves it).
|
||||||
|
//
|
||||||
|
// For each message that was really there it lowers the folder's total, lowers
|
||||||
|
// unread if the message was unread, and gives its bytes back to the mailbox's
|
||||||
|
// quota. A message that is already gone is skipped, touching nothing, which is
|
||||||
|
// what makes a repeated expunge safe. It returns how many messages were
|
||||||
|
// removed.
|
||||||
|
func (s *MailStore) Expunge(ctx context.Context, folderID string, uids []uint32) (int, error) {
|
||||||
|
if len(uids) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("begin transaction: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
if err := lockFolders(ctx, tx, folderID); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
removed := 0
|
||||||
|
for _, uid := range uids {
|
||||||
|
msg, err := loadMessage(ctx, tx, folderID, uid)
|
||||||
|
if errors.Is(err, ErrMessageNotFound) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, `UPDATE email_messages SET expunged_at = NOW() WHERE id = $1 AND received_at = $2`,
|
||||||
|
msg.ID, msg.ReceivedAt); err != nil {
|
||||||
|
return 0, fmt.Errorf("expunge uid %d: %w", uid, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
unreadDelta := 0
|
||||||
|
if !msg.Seen {
|
||||||
|
unreadDelta = -1
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
UPDATE folders
|
||||||
|
SET total_count = GREATEST(0, total_count - 1),
|
||||||
|
unread_count = GREATEST(0, unread_count + $2),
|
||||||
|
highest_modseq = highest_modseq + 1
|
||||||
|
WHERE id = $1`, folderID, unreadDelta); err != nil {
|
||||||
|
return 0, fmt.Errorf("lower folder counters for uid %d: %w", uid, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, `UPDATE mailboxes SET used_bytes = GREATEST(0, used_bytes - $1) WHERE id = $2`,
|
||||||
|
msg.Size, msg.MailboxID); err != nil {
|
||||||
|
return 0, fmt.Errorf("give quota back for uid %d: %w", uid, err)
|
||||||
|
}
|
||||||
|
removed++
|
||||||
|
}
|
||||||
|
|
||||||
|
return removed, tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move relocates one message to another folder of the same mailbox and returns
|
||||||
|
// the UID it has there. The message keeps its identity, its flags and its blob;
|
||||||
|
// only the folder and the UID change, because a UID means something only inside
|
||||||
|
// one folder. The quota does not move: the bytes stay in the same mailbox.
|
||||||
|
func (s *MailStore) Move(ctx context.Context, sourceFolderID string, uid uint32, destFolderID string) (uint32, error) {
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
if err := lockFolders(ctx, tx, sourceFolderID, destFolderID); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
msg, err := loadMessage(ctx, tx, sourceFolderID, uid)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var destUID int64
|
||||||
|
if err := tx.QueryRow(ctx, `SELECT uid_next FROM folders WHERE id = $1`, destFolderID).Scan(&destUID); err != nil {
|
||||||
|
return 0, fmt.Errorf("read destination uid: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
unread := 0
|
||||||
|
if !msg.Seen {
|
||||||
|
unread = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
var modseq int64
|
||||||
|
if err := tx.QueryRow(ctx, `
|
||||||
|
UPDATE folders
|
||||||
|
SET uid_next = uid_next + 1, highest_modseq = highest_modseq + 1,
|
||||||
|
total_count = total_count + 1, unread_count = unread_count + $2
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING highest_modseq`, destFolderID, unread).Scan(&modseq); err != nil {
|
||||||
|
return 0, fmt.Errorf("advance destination folder: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, `UPDATE email_messages SET folder_id = $3, uid = $4, modseq = $5 WHERE id = $1 AND received_at = $2`,
|
||||||
|
msg.ID, msg.ReceivedAt, destFolderID, destUID, modseq); err != nil {
|
||||||
|
return 0, fmt.Errorf("move message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
UPDATE folders
|
||||||
|
SET total_count = GREATEST(0, total_count - 1),
|
||||||
|
unread_count = GREATEST(0, unread_count - $2),
|
||||||
|
highest_modseq = highest_modseq + 1
|
||||||
|
WHERE id = $1`, sourceFolderID, unread); err != nil {
|
||||||
|
return 0, fmt.Errorf("lower source folder counters: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return uint32(destUID), tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy duplicates messages into another folder. Each copy is its own message
|
||||||
|
// (own UID, own flags copied from the original) that shares the original's
|
||||||
|
// blob, and it counts against the quota like any other message. One transaction
|
||||||
|
// per message, so a failure part-way leaves the earlier copies whole.
|
||||||
|
func (s *MailStore) Copy(ctx context.Context, sourceFolderID string, uids []uint32, destFolderID string) ([]port.CopiedMessage, error) {
|
||||||
|
results := make([]port.CopiedMessage, 0, len(uids))
|
||||||
|
for _, uid := range uids {
|
||||||
|
destUID, err := s.copyOne(ctx, sourceFolderID, uid, destFolderID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("copy uid %d: %w", uid, err)
|
||||||
|
}
|
||||||
|
results = append(results, port.CopiedMessage{SourceUID: uid, DestUID: destUID})
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MailStore) copyOne(ctx context.Context, sourceFolderID string, sourceUID uint32, destFolderID string) (uint32, error) {
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("begin transaction: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
if err := lockFolders(ctx, tx, destFolderID); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
src, err := loadMessage(ctx, tx, sourceFolderID, sourceUID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var destUID int64
|
||||||
|
var destMailboxID string
|
||||||
|
if err := tx.QueryRow(ctx, `SELECT uid_next, mailbox_id::text FROM folders WHERE id = $1`, destFolderID).Scan(&destUID, &destMailboxID); err != nil {
|
||||||
|
return 0, fmt.Errorf("read destination folder: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
unread := 0
|
||||||
|
if !src.Seen {
|
||||||
|
unread = 1
|
||||||
|
}
|
||||||
|
var modseq int64
|
||||||
|
if err := tx.QueryRow(ctx, `
|
||||||
|
UPDATE folders
|
||||||
|
SET uid_next = uid_next + 1, highest_modseq = highest_modseq + 1,
|
||||||
|
total_count = total_count + 1, unread_count = unread_count + $2
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING highest_modseq`, destFolderID, unread).Scan(&modseq); err != nil {
|
||||||
|
return 0, fmt.Errorf("advance destination folder: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var newID uuid.UUID
|
||||||
|
var newReceivedAt any
|
||||||
|
if err := tx.QueryRow(ctx, `
|
||||||
|
INSERT INTO email_messages (
|
||||||
|
mailbox_id, folder_id, uid, modseq, blob_id,
|
||||||
|
sender_address, recipient_addresses, size_bytes,
|
||||||
|
spf_result, dkim_result, dmarc_result,
|
||||||
|
from_display_name, subject, snippet, message_id_header, in_reply_to, thread_id, has_attachments,
|
||||||
|
arc_result, dane_status, tls_version, spam_score, spam_verdict, virus_name
|
||||||
|
)
|
||||||
|
SELECT $2::uuid, $3::uuid, $4::bigint, $5::bigint, blob_id,
|
||||||
|
sender_address, recipient_addresses, size_bytes,
|
||||||
|
spf_result, dkim_result, dmarc_result,
|
||||||
|
from_display_name, subject, snippet, message_id_header, in_reply_to, thread_id, has_attachments,
|
||||||
|
arc_result, dane_status, tls_version, spam_score, spam_verdict, virus_name
|
||||||
|
FROM email_messages WHERE id = $1 AND received_at = $6
|
||||||
|
RETURNING id, received_at`,
|
||||||
|
src.ID, destMailboxID, destFolderID, destUID, modseq, src.ReceivedAt).Scan(&newID, &newReceivedAt); err != nil {
|
||||||
|
return 0, fmt.Errorf("insert copied message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, `UPDATE message_blobs SET ref_count = ref_count + 1 WHERE id = (SELECT blob_id FROM email_messages WHERE id = $1 AND received_at = $2)`,
|
||||||
|
newID, newReceivedAt); err != nil {
|
||||||
|
return 0, fmt.Errorf("share the blob: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `UPDATE mailboxes SET used_bytes = used_bytes + $1 WHERE id = $2`, src.Size, destMailboxID); err != nil {
|
||||||
|
return 0, fmt.Errorf("count the copy against the quota: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO message_flags (message_id, received_at, flag)
|
||||||
|
SELECT $1, $2, flag FROM message_flags WHERE message_id = $3 AND received_at = $4`,
|
||||||
|
newID, newReceivedAt, src.ID, src.ReceivedAt); err != nil {
|
||||||
|
return 0, fmt.Errorf("copy flags: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return uint32(destUID), tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteFolder removes a folder the user made, with the mail in it. The
|
||||||
|
// messages are expunged rather than deleted (recoverable, like any expunge) and
|
||||||
|
// their bytes go back to the quota.
|
||||||
|
func (s *MailStore) DeleteFolder(ctx context.Context, folderID string) error {
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
var mailboxID string
|
||||||
|
if err := tx.QueryRow(ctx, `SELECT mailbox_id::text FROM folders WHERE id = $1 FOR UPDATE`, folderID).Scan(&mailboxID); err != nil {
|
||||||
|
return fmt.Errorf("lock folder: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var freed int64
|
||||||
|
if err := tx.QueryRow(ctx, `
|
||||||
|
WITH gone AS (
|
||||||
|
UPDATE email_messages SET expunged_at = NOW()
|
||||||
|
WHERE folder_id = $1 AND expunged_at IS NULL
|
||||||
|
RETURNING size_bytes)
|
||||||
|
SELECT COALESCE(SUM(size_bytes), 0) FROM gone`, folderID).Scan(&freed); err != nil {
|
||||||
|
return fmt.Errorf("expunge the folder's messages: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, `UPDATE mailboxes SET used_bytes = GREATEST(0, used_bytes - $1) WHERE id = $2`, freed, mailboxID); err != nil {
|
||||||
|
return fmt.Errorf("give quota back: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `DELETE FROM folders WHERE id = $1`, folderID); err != nil {
|
||||||
|
return fmt.Errorf("delete folder: %w", err)
|
||||||
|
}
|
||||||
|
return tx.Commit(ctx)
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The read side of MailStore.
|
||||||
|
//
|
||||||
|
// IMAP (LIST, SELECT, FETCH) and the webmail (folder list, message list) used to
|
||||||
|
// read the same tables with different queries, and so could answer differently:
|
||||||
|
// IMAP took a folder's message count from a cached column, the webmail counted
|
||||||
|
// the rows. A client comparing the two saw numbers that disagreed. They now ask
|
||||||
|
// the same question, and the answer is derived from the messages themselves, so
|
||||||
|
// a folder can only ever report what is actually in it.
|
||||||
|
|
||||||
|
// FolderInfo is one folder with the numbers a client needs.
|
||||||
|
type FolderInfo struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
SpecialUse string
|
||||||
|
UIDNext int64
|
||||||
|
UIDValidity int64
|
||||||
|
HighestModSeq int64
|
||||||
|
// Total and Unread are counted from the messages, not read from the cached
|
||||||
|
// columns: the columns are kept true by every write path, and counting here
|
||||||
|
// makes a wrong one visible instead of repeated.
|
||||||
|
Total int
|
||||||
|
Unread int
|
||||||
|
}
|
||||||
|
|
||||||
|
const folderColumns = `
|
||||||
|
SELECT f.id::text, f.name, COALESCE(f.special_use, ''),
|
||||||
|
f.uid_next, f.uid_validity, f.highest_modseq,
|
||||||
|
COUNT(e.id)::int,
|
||||||
|
COUNT(e.id) FILTER (
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM message_flags mf
|
||||||
|
WHERE mf.message_id = e.id
|
||||||
|
AND mf.received_at = e.received_at
|
||||||
|
AND mf.flag = '\Seen')
|
||||||
|
)::int
|
||||||
|
FROM folders f
|
||||||
|
LEFT JOIN email_messages e ON e.folder_id = f.id AND e.expunged_at IS NULL`
|
||||||
|
|
||||||
|
func scanFolder(row pgx.Row) (FolderInfo, error) {
|
||||||
|
var f FolderInfo
|
||||||
|
err := row.Scan(&f.ID, &f.Name, &f.SpecialUse, &f.UIDNext, &f.UIDValidity, &f.HighestModSeq, &f.Total, &f.Unread)
|
||||||
|
return f, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Folders lists a mailbox's folders by name.
|
||||||
|
func (s *MailStore) Folders(ctx context.Context, mailboxID string) ([]FolderInfo, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, folderColumns+`
|
||||||
|
WHERE f.mailbox_id = $1
|
||||||
|
GROUP BY f.id
|
||||||
|
ORDER BY f.name`, mailboxID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list folders: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []FolderInfo
|
||||||
|
for rows.Next() {
|
||||||
|
f, err := scanFolder(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, f)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// FolderByName finds one folder by its exact name; nil, nil when there is none
|
||||||
|
// (IMAP SELECT treats that as a normal answer, not a failure).
|
||||||
|
func (s *MailStore) FolderByName(ctx context.Context, mailboxID, name string) (*FolderInfo, error) {
|
||||||
|
f, err := scanFolder(s.pool.QueryRow(ctx, folderColumns+`
|
||||||
|
WHERE f.mailbox_id = $1 AND f.name = $2
|
||||||
|
GROUP BY f.id`, mailboxID, name))
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessageInfo is one message as either a mail client or the webmail lists it.
|
||||||
|
type MessageInfo struct {
|
||||||
|
UID uint32
|
||||||
|
BlobID uuid.UUID
|
||||||
|
Size int64
|
||||||
|
ReceivedAt time.Time
|
||||||
|
ModSeq uint64
|
||||||
|
Flags []string
|
||||||
|
Subject string
|
||||||
|
SenderAddress string
|
||||||
|
FromName string
|
||||||
|
Snippet string
|
||||||
|
HasAttachments bool
|
||||||
|
SpamVerdict string
|
||||||
|
DmarcResult string
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasFlag reports whether the message carries the flag.
|
||||||
|
func (m MessageInfo) HasFlag(flag string) bool {
|
||||||
|
for _, f := range m.Flags {
|
||||||
|
if f == flag {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessageQuery shapes a listing. The zero value lists every message of the
|
||||||
|
// folder by UID ascending, which is what IMAP needs to number them.
|
||||||
|
type MessageQuery struct {
|
||||||
|
// NewestFirst orders by arrival, newest first, as the webmail shows them
|
||||||
|
NewestFirst bool
|
||||||
|
// Search keeps messages whose subject, sender or snippet contains it
|
||||||
|
Search string
|
||||||
|
// Limit and Offset page the result; Limit 0 means no limit
|
||||||
|
Limit int
|
||||||
|
Offset int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Messages lists a folder's live messages with their flags.
|
||||||
|
func (s *MailStore) Messages(ctx context.Context, folderID string, q MessageQuery) ([]MessageInfo, error) {
|
||||||
|
order := `e.uid ASC`
|
||||||
|
if q.NewestFirst {
|
||||||
|
order = `e.received_at DESC, e.uid DESC`
|
||||||
|
}
|
||||||
|
limit := q.Limit
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 1<<31 - 1
|
||||||
|
}
|
||||||
|
offset := q.Offset
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT e.uid, e.blob_id, e.size_bytes, e.received_at, e.modseq,
|
||||||
|
COALESCE((SELECT array_agg(mf.flag) FROM message_flags mf
|
||||||
|
WHERE mf.message_id = e.id AND mf.received_at = e.received_at), '{}'),
|
||||||
|
COALESCE(e.subject, ''), e.sender_address::text,
|
||||||
|
COALESCE(e.from_display_name, ''), COALESCE(e.snippet, ''),
|
||||||
|
e.has_attachments, COALESCE(e.spam_verdict, ''), COALESCE(e.dmarc_result, '')
|
||||||
|
FROM email_messages e
|
||||||
|
WHERE e.folder_id = $1 AND e.expunged_at IS NULL
|
||||||
|
AND ($2 = '' OR e.subject ILIKE '%' || $2 || '%'
|
||||||
|
OR e.sender_address::text ILIKE '%' || $2 || '%'
|
||||||
|
OR e.snippet ILIKE '%' || $2 || '%')
|
||||||
|
ORDER BY `+order+`
|
||||||
|
LIMIT $3 OFFSET $4`, folderID, q.Search, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list messages: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []MessageInfo
|
||||||
|
for rows.Next() {
|
||||||
|
var m MessageInfo
|
||||||
|
if err := rows.Scan(&m.UID, &m.BlobID, &m.Size, &m.ReceivedAt, &m.ModSeq, &m.Flags,
|
||||||
|
&m.Subject, &m.SenderAddress, &m.FromName, &m.Snippet,
|
||||||
|
&m.HasAttachments, &m.SpamVerdict, &m.DmarcResult); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessageBlob resolves one message to its stored blob, or ErrMessageNotFound.
|
||||||
|
func (s *MailStore) MessageBlob(ctx context.Context, folderID string, uid uint32) (uuid.UUID, error) {
|
||||||
|
var blobID uuid.UUID
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT blob_id FROM email_messages WHERE folder_id = $1 AND uid = $2 AND expunged_at IS NULL`,
|
||||||
|
folderID, int64(uid)).Scan(&blobID)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return uuid.Nil, ErrMessageNotFound
|
||||||
|
}
|
||||||
|
return blobID, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// MailboxIDByAddress resolves an active address to its mailbox id.
|
||||||
|
func (s *MailStore) MailboxIDByAddress(ctx context.Context, address string) (string, error) {
|
||||||
|
var id string
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT m.id::text FROM mailboxes m
|
||||||
|
JOIN domains d ON d.id = m.domain_id
|
||||||
|
WHERE m.email_address = $1 AND m.is_active AND d.is_active`, address).Scan(&id)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return "", ErrMessageNotFound
|
||||||
|
}
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// runMigration0014 applies the migration file itself, so the test cannot drift
|
||||||
|
// from what production runs. It is idempotent by design, which is also tested.
|
||||||
|
func runMigration0014(t *testing.T, pool *pgxpool.Pool) {
|
||||||
|
t.Helper()
|
||||||
|
sql, err := os.ReadFile("../../../../../migrations/0014_mailbox_state_single_source.up.sql")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read migration: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(context.Background(), string(sql)); err != nil {
|
||||||
|
t.Fatalf("apply migration 0014: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func folderNames(t *testing.T, pool *pgxpool.Pool, mailboxID string) map[string]string {
|
||||||
|
t.Helper()
|
||||||
|
rows, err := pool.Query(context.Background(), `SELECT name, COALESCE(special_use, '') FROM folders WHERE mailbox_id = $1`, mailboxID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list folders: %v", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := map[string]string{}
|
||||||
|
for rows.Next() {
|
||||||
|
var name, role string
|
||||||
|
if err := rows.Scan(&name, &role); err != nil {
|
||||||
|
t.Fatalf("scan: %v", err)
|
||||||
|
}
|
||||||
|
out[name] = role
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Numbers that two paths left wrong are corrected from the rows.
|
||||||
|
func TestMigration0014_ReconcilesDriftedCountersAndQuota(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
mailboxID := seedMailbox(t, pool)
|
||||||
|
deliver(t, pool, mailboxID, "INBOX", "a", false)
|
||||||
|
deliver(t, pool, mailboxID, "INBOX", "b", true)
|
||||||
|
deliver(t, pool, mailboxID, "Sent", "c", true)
|
||||||
|
|
||||||
|
// The drift as it was found: counters and quota that disagree with the rows
|
||||||
|
if _, err := pool.Exec(ctx, `UPDATE folders SET unread_count = 9, total_count = 7 WHERE mailbox_id = $1`, mailboxID); err != nil {
|
||||||
|
t.Fatalf("corrupt counters: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, `UPDATE mailboxes SET used_bytes = 12345 WHERE id = $1`, mailboxID); err != nil {
|
||||||
|
t.Fatalf("corrupt quota: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
runMigration0014(t, pool)
|
||||||
|
assertConsistent(t, pool, mailboxID, "the reconciliation")
|
||||||
|
|
||||||
|
runMigration0014(t, pool)
|
||||||
|
assertConsistent(t, pool, mailboxID, "running the reconciliation a second time")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A mailbox that is already right is left exactly as it was.
|
||||||
|
func TestMigration0014_LeavesATrueMailboxAlone(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
mailboxID := seedMailbox(t, pool)
|
||||||
|
deliver(t, pool, mailboxID, "INBOX", "a", false)
|
||||||
|
before, usedBefore, _ := mailState(t, pool, mailboxID)
|
||||||
|
|
||||||
|
runMigration0014(t, pool)
|
||||||
|
|
||||||
|
after, usedAfter, _ := mailState(t, pool, mailboxID)
|
||||||
|
for name, f := range before {
|
||||||
|
if after[name].CachedTotal != f.CachedTotal || after[name].CachedUnread != f.CachedUnread || after[name].HighestModSeq != f.HighestModSeq {
|
||||||
|
t.Errorf("%s changed: %+v -> %+v", name, f, after[name])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if usedAfter != usedBefore {
|
||||||
|
t.Errorf("used_bytes changed from %d to %d", usedBefore, usedAfter)
|
||||||
|
}
|
||||||
|
_ = ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// The one list of standard folders: what is missing is added, what is there is
|
||||||
|
// not duplicated, and running it again changes nothing.
|
||||||
|
func TestProvisionDefaultFolders(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
runMigration0014(t, pool)
|
||||||
|
|
||||||
|
domainID, mailboxID := uuid.New(), uuid.New()
|
||||||
|
name := "prov" + uuid.NewString()[:8] + ".test"
|
||||||
|
if _, err := pool.Exec(ctx, `INSERT INTO domains (id, name, punycode_name, is_active) VALUES ($1,$2,$3,true)`, domainID, name, name); err != nil {
|
||||||
|
t.Fatalf("seed domain: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `DELETE FROM domains WHERE id = $1`, domainID) })
|
||||||
|
if _, err := pool.Exec(ctx, `INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash, is_active) VALUES ($1,$2,'u',$3,'x',true)`,
|
||||||
|
mailboxID, domainID, "u@"+name); err != nil {
|
||||||
|
t.Fatalf("seed mailbox: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := map[string]string{"INBOX": "inbox", "Sent": "sent", "Drafts": "drafts", "Trash": "trash", "Junk": "junk", "Archive": "archive", "Reports": ""}
|
||||||
|
|
||||||
|
if _, err := pool.Exec(ctx, `SELECT provision_default_folders($1)`, mailboxID); err != nil {
|
||||||
|
t.Fatalf("provision: %v", err)
|
||||||
|
}
|
||||||
|
got := folderNames(t, pool, mailboxID.String())
|
||||||
|
for n, role := range want {
|
||||||
|
if r, ok := got[n]; !ok || r != role {
|
||||||
|
t.Errorf("folder %q: present=%v role=%q, want role %q", n, ok, r, role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Errorf("got %d folders, want %d: %v", len(got), len(want), got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := pool.Exec(ctx, `SELECT provision_default_folders($1)`, mailboxID); err != nil {
|
||||||
|
t.Fatalf("provision again: %v", err)
|
||||||
|
}
|
||||||
|
if again := folderNames(t, pool, mailboxID.String()); len(again) != len(want) {
|
||||||
|
t.Errorf("a second call changed the folder count to %d", len(again))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A folder that exists under another capitalisation counts as present, and the
|
||||||
|
// folder keeps the owner's mail.
|
||||||
|
func TestProvisionDefaultFolders_DoesNotDuplicateByCase(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
runMigration0014(t, pool)
|
||||||
|
mailboxID := seedMailbox(t, pool) // INBOX, Sent, Drafts, Trash
|
||||||
|
|
||||||
|
if _, err := pool.Exec(ctx, `INSERT INTO folders (mailbox_id, name, special_use) VALUES ($1, 'reports', NULL)`, mailboxID); err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, `SELECT provision_default_folders($1)`, mailboxID); err != nil {
|
||||||
|
t.Fatalf("provision: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := folderNames(t, pool, mailboxID)
|
||||||
|
reports := 0
|
||||||
|
for n := range got {
|
||||||
|
if n == "reports" || n == "Reports" {
|
||||||
|
reports++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if reports != 1 {
|
||||||
|
t.Errorf("found %d reports folders, want 1: %v", reports, got)
|
||||||
|
}
|
||||||
|
if len(got) != 7 {
|
||||||
|
t.Errorf("got %d folders, want 7: %v", len(got), got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,37 +8,26 @@ import (
|
|||||||
"lambdamail/protocols/internal/application/port"
|
"lambdamail/protocols/internal/application/port"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MessageQueryRepository implements port.MessageQueryRepository against Postgres.
|
// MessageQueryRepository lists a folder's messages for IMAP FETCH, through the
|
||||||
|
// same MailStore listing the webmail uses.
|
||||||
type MessageQueryRepository struct {
|
type MessageQueryRepository struct {
|
||||||
pool *pgxpool.Pool
|
store *MailStore
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMessageQueryRepository(pool *pgxpool.Pool) *MessageQueryRepository {
|
func NewMessageQueryRepository(pool *pgxpool.Pool) *MessageQueryRepository {
|
||||||
return &MessageQueryRepository{pool: pool}
|
return &MessageQueryRepository{store: NewMailStore(pool)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *MessageQueryRepository) ListMessages(ctx context.Context, folderID string) ([]port.MessageRecord, error) {
|
func (r *MessageQueryRepository) ListMessages(ctx context.Context, folderID string) ([]port.MessageRecord, error) {
|
||||||
rows, err := r.pool.Query(ctx, `
|
messages, err := r.store.Messages(ctx, folderID, MessageQuery{})
|
||||||
SELECT m.uid, m.blob_id, m.size_bytes, m.received_at, m.modseq,
|
|
||||||
COALESCE(array_agg(f.flag) FILTER (WHERE f.flag IS NOT NULL), '{}')
|
|
||||||
FROM email_messages m
|
|
||||||
LEFT JOIN message_flags f ON f.message_id = m.id AND f.received_at = m.received_at
|
|
||||||
WHERE m.folder_id = $1 AND m.expunged_at IS NULL
|
|
||||||
GROUP BY m.uid, m.blob_id, m.size_bytes, m.received_at, m.modseq
|
|
||||||
ORDER BY m.uid ASC
|
|
||||||
`, folderID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var out []port.MessageRecord
|
var out []port.MessageRecord
|
||||||
for rows.Next() {
|
for _, m := range messages {
|
||||||
var rec port.MessageRecord
|
out = append(out, port.MessageRecord{
|
||||||
if err := rows.Scan(&rec.UID, &rec.BlobID, &rec.SizeBytes, &rec.ReceivedAt, &rec.ModSeq, &rec.Flags); err != nil {
|
UID: m.UID, BlobID: m.BlobID, SizeBytes: m.Size, ReceivedAt: m.ReceivedAt, Flags: m.Flags, ModSeq: m.ModSeq,
|
||||||
return nil, err
|
})
|
||||||
}
|
|
||||||
out = append(out, rec)
|
|
||||||
}
|
}
|
||||||
return out, rows.Err()
|
return out, nil
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
@@ -19,456 +20,182 @@ var ErrMessageNotFound = errors.New("webmail: message not found")
|
|||||||
|
|
||||||
// WebmailRepository implements the webmail read side against Postgres.
|
// WebmailRepository implements the webmail read side against Postgres.
|
||||||
type WebmailRepository struct {
|
type WebmailRepository struct {
|
||||||
pool *pgxpool.Pool
|
pool *pgxpool.Pool
|
||||||
|
store *MailStore
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewWebmailRepository(pool *pgxpool.Pool) *WebmailRepository {
|
func NewWebmailRepository(pool *pgxpool.Pool) *WebmailRepository {
|
||||||
return &WebmailRepository{pool: pool}
|
return &WebmailRepository{pool: pool, store: NewMailStore(pool)}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The methods below only read. They do not query on their own: the webmail asks
|
||||||
|
// MailStore the same questions IMAP does and shapes the answers for the
|
||||||
|
// browser, so a folder shows the same numbers in both.
|
||||||
|
|
||||||
func (r *WebmailRepository) FindMailboxIDByAddress(ctx context.Context, address string) (string, error) {
|
func (r *WebmailRepository) FindMailboxIDByAddress(ctx context.Context, address string) (string, error) {
|
||||||
var id string
|
return r.store.MailboxIDByAddress(ctx, address)
|
||||||
err := r.pool.QueryRow(ctx, `
|
|
||||||
SELECT m.id::text FROM mailboxes m
|
|
||||||
JOIN domains d ON d.id = m.domain_id
|
|
||||||
WHERE m.email_address = $1 AND m.is_active AND d.is_active
|
|
||||||
`, address).Scan(&id)
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return "", ErrMessageNotFound
|
|
||||||
}
|
|
||||||
return id, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// roleOrder is how the folder list is arranged for people: the folders they use
|
||||||
|
// every day first, in a fixed order, then their own by name.
|
||||||
|
var roleOrder = map[string]int{"inbox": 0, "drafts": 1, "sent": 2, "archive": 3, "junk": 4, "trash": 5}
|
||||||
|
|
||||||
func (r *WebmailRepository) ListFolders(ctx context.Context, mailboxID string) ([]port.WebmailFolder, error) {
|
func (r *WebmailRepository) ListFolders(ctx context.Context, mailboxID string) ([]port.WebmailFolder, error) {
|
||||||
// The counts are derived from the messages themselves rather than read out
|
folders, err := r.store.Folders(ctx, mailboxID)
|
||||||
// of folders.unread_count/total_count.
|
|
||||||
//
|
|
||||||
// Those columns are a denormalised cache maintained by every write path,
|
|
||||||
// and once any one of them was wrong nothing brought it back: the number
|
|
||||||
// the user saw stayed wrong through a reload, a logout and a fresh login,
|
|
||||||
// because the wrongness was stored. Deriving here is authoritative and
|
|
||||||
// self-healing - a folder can only ever report what is actually in it.
|
|
||||||
//
|
|
||||||
// The columns stay maintained for IMAP's sake (STATUS answers from them),
|
|
||||||
// so this is a correction at the read side, not their removal.
|
|
||||||
rows, err := r.pool.Query(ctx, `
|
|
||||||
SELECT f.id::text, f.name, COALESCE(f.special_use, ''),
|
|
||||||
COUNT(e.id) FILTER (
|
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM message_flags mf
|
|
||||||
WHERE mf.message_id = e.id
|
|
||||||
AND mf.received_at = e.received_at
|
|
||||||
AND mf.flag = '\Seen')
|
|
||||||
)::int AS unread_count,
|
|
||||||
COUNT(e.id)::int AS total_count
|
|
||||||
FROM folders f
|
|
||||||
LEFT JOIN email_messages e
|
|
||||||
ON e.folder_id = f.id AND e.expunged_at IS NULL
|
|
||||||
WHERE f.mailbox_id = $1
|
|
||||||
GROUP BY f.id, f.name, f.special_use
|
|
||||||
ORDER BY CASE f.special_use
|
|
||||||
WHEN 'inbox' THEN 0 WHEN 'drafts' THEN 1 WHEN 'sent' THEN 2
|
|
||||||
WHEN 'archive' THEN 3 WHEN 'junk' THEN 4 WHEN 'trash' THEN 5
|
|
||||||
ELSE 6 END, f.name
|
|
||||||
`, mailboxID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list folders: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
folders := []port.WebmailFolder{}
|
out := make([]port.WebmailFolder, 0, len(folders))
|
||||||
for rows.Next() {
|
for _, f := range folders {
|
||||||
var f port.WebmailFolder
|
out = append(out, port.WebmailFolder{ID: f.ID, Name: f.Name, SpecialUse: f.SpecialUse, UnreadCount: f.Unread, TotalCount: f.Total})
|
||||||
if err := rows.Scan(&f.ID, &f.Name, &f.SpecialUse, &f.UnreadCount, &f.TotalCount); err != nil {
|
}
|
||||||
return nil, err
|
|
||||||
|
rank := func(f port.WebmailFolder) int {
|
||||||
|
if n, ok := roleOrder[f.SpecialUse]; ok {
|
||||||
|
return n
|
||||||
}
|
}
|
||||||
folders = append(folders, f)
|
return 6
|
||||||
}
|
}
|
||||||
return folders, rows.Err()
|
sort.SliceStable(out, func(i, j int) bool {
|
||||||
|
if ri, rj := rank(out[i]), rank(out[j]); ri != rj {
|
||||||
|
return ri < rj
|
||||||
|
}
|
||||||
|
return out[i].Name < out[j].Name
|
||||||
|
})
|
||||||
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// folderCondition matches either the special-use role ("inbox") or the literal
|
|
||||||
// folder name, so the UI can address a folder the same way IMAP does.
|
|
||||||
const folderCondition = `(f.special_use = LOWER($2) OR LOWER(f.name) = LOWER($2))`
|
|
||||||
|
|
||||||
func (r *WebmailRepository) ListMessages(
|
func (r *WebmailRepository) ListMessages(
|
||||||
ctx context.Context, mailboxID, folderName, search string, limit, offset int,
|
ctx context.Context, mailboxID, folderName, search string, limit, offset int,
|
||||||
) ([]port.WebmailMessage, error) {
|
) ([]port.WebmailMessage, error) {
|
||||||
if limit <= 0 || limit > 200 {
|
if limit <= 0 || limit > 200 {
|
||||||
limit = 50
|
limit = 50
|
||||||
}
|
}
|
||||||
if offset < 0 {
|
|
||||||
offset = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := r.pool.Query(ctx, `
|
folderID, err := r.store.ResolveFolder(ctx, mailboxID, folderName)
|
||||||
SELECT e.uid, COALESCE(e.subject, ''), e.sender_address::text,
|
if errors.Is(err, port.ErrFolderMissing) {
|
||||||
COALESCE(e.from_display_name, ''), COALESCE(e.snippet, ''),
|
return []port.WebmailMessage{}, nil
|
||||||
e.size_bytes, e.received_at, e.has_attachments,
|
}
|
||||||
EXISTS (SELECT 1 FROM message_flags mf
|
|
||||||
WHERE mf.message_id = e.id AND mf.received_at = e.received_at AND mf.flag = '\Seen'),
|
|
||||||
EXISTS (SELECT 1 FROM message_flags mf
|
|
||||||
WHERE mf.message_id = e.id AND mf.received_at = e.received_at AND mf.flag = '\Flagged'),
|
|
||||||
EXISTS (SELECT 1 FROM message_flags mf
|
|
||||||
WHERE mf.message_id = e.id AND mf.received_at = e.received_at AND mf.flag = '\Answered'),
|
|
||||||
COALESCE(e.spam_verdict, ''), COALESCE(e.dmarc_result, '')
|
|
||||||
FROM email_messages e
|
|
||||||
JOIN folders f ON f.id = e.folder_id
|
|
||||||
WHERE e.mailbox_id = $1 AND `+folderCondition+`
|
|
||||||
AND e.expunged_at IS NULL
|
|
||||||
AND ($3 = '' OR e.subject ILIKE '%%' || $3 || '%%'
|
|
||||||
OR e.sender_address::text ILIKE '%%' || $3 || '%%'
|
|
||||||
OR e.snippet ILIKE '%%' || $3 || '%%')
|
|
||||||
ORDER BY e.received_at DESC, e.uid DESC
|
|
||||||
LIMIT $4 OFFSET $5
|
|
||||||
`, mailboxID, folderName, search, limit, offset)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list messages: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
messages := []port.WebmailMessage{}
|
messages, err := r.store.Messages(ctx, folderID, MessageQuery{NewestFirst: true, Search: search, Limit: limit, Offset: offset})
|
||||||
for rows.Next() {
|
if err != nil {
|
||||||
var m port.WebmailMessage
|
return nil, err
|
||||||
if err := rows.Scan(
|
|
||||||
&m.UID, &m.Subject, &m.SenderAddress, &m.FromName, &m.Snippet,
|
|
||||||
&m.SizeBytes, &m.ReceivedAt, &m.HasAttachments,
|
|
||||||
&m.Seen, &m.Flagged, &m.Answered, &m.SpamVerdict, &m.DmarcResult,
|
|
||||||
); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
messages = append(messages, m)
|
|
||||||
}
|
}
|
||||||
return messages, rows.Err()
|
|
||||||
|
out := make([]port.WebmailMessage, 0, len(messages))
|
||||||
|
for _, m := range messages {
|
||||||
|
out = append(out, port.WebmailMessage{
|
||||||
|
UID: m.UID, Subject: m.Subject, SenderAddress: m.SenderAddress, FromName: m.FromName, Snippet: m.Snippet,
|
||||||
|
SizeBytes: m.Size, ReceivedAt: m.ReceivedAt, HasAttachments: m.HasAttachments,
|
||||||
|
Seen: m.HasFlag(`\Seen`), Flagged: m.HasFlag(`\Flagged`), Answered: m.HasFlag(`\Answered`),
|
||||||
|
SpamVerdict: m.SpamVerdict, DmarcResult: m.DmarcResult,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *WebmailRepository) GetMessageBlob(
|
func (r *WebmailRepository) GetMessageBlob(ctx context.Context, mailboxID, folderName string, uid uint32) (uuid.UUID, error) {
|
||||||
ctx context.Context, mailboxID, folderName string, uid uint32,
|
folderID, err := r.messageFolder(ctx, mailboxID, folderName)
|
||||||
) (uuid.UUID, error) {
|
if err != nil {
|
||||||
var blobID uuid.UUID
|
return uuid.Nil, err
|
||||||
err := r.pool.QueryRow(ctx, `
|
|
||||||
SELECT e.blob_id FROM email_messages e
|
|
||||||
JOIN folders f ON f.id = e.folder_id
|
|
||||||
WHERE e.mailbox_id = $1 AND `+folderCondition+`
|
|
||||||
AND e.uid = $3 AND e.expunged_at IS NULL
|
|
||||||
`, mailboxID, folderName, int64(uid)).Scan(&blobID)
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return uuid.Nil, ErrMessageNotFound
|
|
||||||
}
|
}
|
||||||
return blobID, err
|
return r.store.MessageBlob(ctx, folderID, uid)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarkSeen keeps the \Seen flag and the folder's unread counter in step. They
|
// The methods below change state. They do not do it themselves: the webmail
|
||||||
// are updated together in one transaction, because a counter that disagrees
|
// names a message by mailbox, folder name and UID, MailStore by folder id and
|
||||||
// with the flags is what makes an inbox show unread mail that is not there.
|
// UID, and these translate. Every counter, modseq and quota rule lives in
|
||||||
func (r *WebmailRepository) MarkSeen(
|
// MailStore, which IMAP uses too, so the two can no longer disagree.
|
||||||
ctx context.Context, mailboxID, folderName string, uid uint32, seen bool,
|
|
||||||
) error {
|
// messageFolder resolves the folder a message is addressed in. A folder that is
|
||||||
tx, err := r.pool.Begin(ctx)
|
// not there and a message that is not there look the same to the caller, so a
|
||||||
|
// stranger cannot probe which folders exist.
|
||||||
|
func (r *WebmailRepository) messageFolder(ctx context.Context, mailboxID, folderName string) (string, error) {
|
||||||
|
id, err := r.store.ResolveFolder(ctx, mailboxID, folderName)
|
||||||
|
if errors.Is(err, port.ErrFolderMissing) {
|
||||||
|
return "", ErrMessageNotFound
|
||||||
|
}
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkSeen sets or clears \Seen. The unread counter follows inside MailStore.
|
||||||
|
func (r *WebmailRepository) MarkSeen(ctx context.Context, mailboxID, folderName string, uid uint32, seen bool) error {
|
||||||
|
return r.setFlag(ctx, mailboxID, folderName, uid, `\Seen`, seen)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkFlagged sets or clears \Flagged.
|
||||||
|
func (r *WebmailRepository) MarkFlagged(ctx context.Context, mailboxID, folderName string, uid uint32, flagged bool) error {
|
||||||
|
return r.setFlag(ctx, mailboxID, folderName, uid, `\Flagged`, flagged)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *WebmailRepository) setFlag(ctx context.Context, mailboxID, folderName string, uid uint32, flag string, on bool) error {
|
||||||
|
folderID, err := r.messageFolder(ctx, mailboxID, folderName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer tx.Rollback(ctx)
|
op := port.FlagOpAdd
|
||||||
|
if !on {
|
||||||
|
op = port.FlagOpDel
|
||||||
|
}
|
||||||
|
_, err = r.store.SetFlags(ctx, folderID, uid, op, []string{flag}, 0)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
var messageID uuid.UUID
|
// Expunge removes one message for good (soft delete) and gives its bytes back.
|
||||||
var receivedAt any
|
func (r *WebmailRepository) Expunge(ctx context.Context, mailboxID, folderName string, uid uint32) error {
|
||||||
var folderID string
|
folderID, err := r.messageFolder(ctx, mailboxID, folderName)
|
||||||
err = tx.QueryRow(ctx, `
|
if err != nil {
|
||||||
SELECT e.id, e.received_at, f.id::text FROM email_messages e
|
return err
|
||||||
JOIN folders f ON f.id = e.folder_id
|
}
|
||||||
WHERE e.mailbox_id = $1 AND `+folderCondition+`
|
removed, err := r.store.Expunge(ctx, folderID, []uint32{uid})
|
||||||
AND e.uid = $3 AND e.expunged_at IS NULL
|
if err != nil {
|
||||||
`, mailboxID, folderName, int64(uid)).Scan(&messageID, &receivedAt, &folderID)
|
return err
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
}
|
||||||
|
if removed == 0 {
|
||||||
return ErrMessageNotFound
|
return ErrMessageNotFound
|
||||||
}
|
}
|
||||||
if err != nil {
|
return nil
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var changed int64
|
|
||||||
if seen {
|
|
||||||
tag, err := tx.Exec(ctx, `
|
|
||||||
INSERT INTO message_flags (message_id, received_at, flag)
|
|
||||||
VALUES ($1, $2, '\Seen') ON CONFLICT DO NOTHING
|
|
||||||
`, messageID, receivedAt)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
changed = tag.RowsAffected()
|
|
||||||
} else {
|
|
||||||
tag, err := tx.Exec(ctx, `
|
|
||||||
DELETE FROM message_flags WHERE message_id = $1 AND received_at = $2 AND flag = '\Seen'
|
|
||||||
`, messageID, receivedAt)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
changed = tag.RowsAffected()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only a real transition moves the counter, so opening an already-read
|
|
||||||
// message repeatedly cannot drive unread_count negative.
|
|
||||||
if changed > 0 {
|
|
||||||
delta := 1
|
|
||||||
if seen {
|
|
||||||
delta = -1
|
|
||||||
}
|
|
||||||
if _, err := tx.Exec(ctx, `
|
|
||||||
UPDATE folders SET unread_count = GREATEST(0, unread_count + $2), highest_modseq = highest_modseq + 1
|
|
||||||
WHERE id = $1
|
|
||||||
`, folderID, delta); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return tx.Commit(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MarkFlagged sets or clears \Flagged. Unlike \Seen it moves no counter, but a
|
|
||||||
// real change still bumps the folder's modseq: that is how an IMAP client
|
|
||||||
// holding the folder open learns the flag changed without a full resync.
|
|
||||||
func (r *WebmailRepository) MarkFlagged(
|
|
||||||
ctx context.Context, mailboxID, folderName string, uid uint32, flagged bool,
|
|
||||||
) error {
|
|
||||||
tx, err := r.pool.Begin(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer tx.Rollback(ctx)
|
|
||||||
|
|
||||||
var messageID uuid.UUID
|
|
||||||
var receivedAt any
|
|
||||||
var folderID string
|
|
||||||
err = tx.QueryRow(ctx, `
|
|
||||||
SELECT e.id, e.received_at, f.id::text FROM email_messages e
|
|
||||||
JOIN folders f ON f.id = e.folder_id
|
|
||||||
WHERE e.mailbox_id = $1 AND `+folderCondition+`
|
|
||||||
AND e.uid = $3 AND e.expunged_at IS NULL
|
|
||||||
`, mailboxID, folderName, int64(uid)).Scan(&messageID, &receivedAt, &folderID)
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return ErrMessageNotFound
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var changed int64
|
|
||||||
if flagged {
|
|
||||||
tag, err := tx.Exec(ctx, `
|
|
||||||
INSERT INTO message_flags (message_id, received_at, flag)
|
|
||||||
VALUES ($1, $2, '\Flagged') ON CONFLICT DO NOTHING
|
|
||||||
`, messageID, receivedAt)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
changed = tag.RowsAffected()
|
|
||||||
} else {
|
|
||||||
tag, err := tx.Exec(ctx, `
|
|
||||||
DELETE FROM message_flags WHERE message_id = $1 AND received_at = $2 AND flag = '\Flagged'
|
|
||||||
`, messageID, receivedAt)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
changed = tag.RowsAffected()
|
|
||||||
}
|
|
||||||
|
|
||||||
if changed > 0 {
|
|
||||||
if _, err := tx.Exec(ctx,
|
|
||||||
`UPDATE folders SET highest_modseq = highest_modseq + 1 WHERE id = $1`, folderID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return tx.Commit(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expunge marks one message deleted and keeps the folder counters in step.
|
|
||||||
//
|
|
||||||
// Soft delete, matching the expunged_at column the rest of the schema uses: the
|
|
||||||
// blob stays referenced until the retention sweep, so a mistaken delete is
|
|
||||||
// recoverable and IMAP clients holding the UID still resolve it.
|
|
||||||
//
|
|
||||||
// The message is located by mailbox as well as UID, so a caller cannot expunge
|
|
||||||
// somebody else's mail by guessing a number.
|
|
||||||
func (r *WebmailRepository) Expunge(
|
|
||||||
ctx context.Context, mailboxID, folderName string, uid uint32,
|
|
||||||
) error {
|
|
||||||
tx, err := r.pool.Begin(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer tx.Rollback(ctx)
|
|
||||||
|
|
||||||
var messageID uuid.UUID
|
|
||||||
var folderID string
|
|
||||||
var wasUnread bool
|
|
||||||
err = tx.QueryRow(ctx, `
|
|
||||||
SELECT e.id, f.id::text,
|
|
||||||
NOT EXISTS (SELECT 1 FROM message_flags mf
|
|
||||||
WHERE mf.message_id = e.id
|
|
||||||
AND mf.received_at = e.received_at
|
|
||||||
AND mf.flag = '\Seen')
|
|
||||||
FROM email_messages e
|
|
||||||
JOIN folders f ON f.id = e.folder_id
|
|
||||||
WHERE e.mailbox_id = $1 AND `+folderCondition+`
|
|
||||||
AND e.uid = $3 AND e.expunged_at IS NULL
|
|
||||||
`, mailboxID, folderName, int64(uid)).Scan(&messageID, &folderID, &wasUnread)
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return ErrMessageNotFound
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := tx.Exec(ctx,
|
|
||||||
`UPDATE email_messages SET expunged_at = NOW() WHERE id = $1`, messageID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// GREATEST guards the counters against ever going negative, which a
|
|
||||||
// double expunge of the same row would otherwise cause.
|
|
||||||
unreadDelta := 0
|
|
||||||
if wasUnread {
|
|
||||||
unreadDelta = -1
|
|
||||||
}
|
|
||||||
if _, err := tx.Exec(ctx, `
|
|
||||||
UPDATE folders
|
|
||||||
SET total_count = GREATEST(0, total_count - 1),
|
|
||||||
unread_count = GREATEST(0, unread_count + $2),
|
|
||||||
highest_modseq = highest_modseq + 1
|
|
||||||
WHERE id = $1
|
|
||||||
`, folderID, unreadDelta); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return tx.Commit(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MoveToTrash relocates one message into the mailbox's Trash folder and
|
|
||||||
// returns the UID it was given there.
|
|
||||||
//
|
|
||||||
// This is what "delete" means in a mail client, and the webmail had no way to
|
|
||||||
// do it at all: the only removal in the codebase was Expunge, reachable solely
|
|
||||||
// when an autosave superseded a draft. A message the user wanted rid of - the
|
|
||||||
// empty draft left behind by a sent message, most visibly - could not be
|
|
||||||
// removed from any screen.
|
|
||||||
//
|
|
||||||
// The message keeps its identity and its blob; only the folder and the UID
|
|
||||||
// change, because a UID is meaningful only within one folder. Deleting from
|
|
||||||
// Trash itself is Expunge's job, not this one's.
|
|
||||||
func (r *WebmailRepository) MoveToTrash(
|
|
||||||
ctx context.Context, mailboxID, folderName string, uid uint32,
|
|
||||||
) (uint32, error) {
|
|
||||||
return r.moveInto(ctx, mailboxID, folderName, uid, trashTarget)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MoveToFolder files one message into a named folder.
|
|
||||||
//
|
|
||||||
// The target is matched on its special-use role or its name, the same way the
|
|
||||||
// rest of this repository addresses folders, so "archive" and "Archive" both
|
|
||||||
// reach the same place and a folder the user created themselves - which has no
|
|
||||||
// role - is reachable by name.
|
|
||||||
func (r *WebmailRepository) MoveToFolder(
|
|
||||||
ctx context.Context, mailboxID, folderName string, uid uint32, target string,
|
|
||||||
) (uint32, error) {
|
|
||||||
return r.moveInto(ctx, mailboxID, folderName, uid, target)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// trashTarget is what MoveToTrash resolves; kept as a constant so the two
|
// trashTarget is what MoveToTrash resolves; kept as a constant so the two
|
||||||
// callers cannot drift.
|
// callers cannot drift.
|
||||||
const trashTarget = "trash"
|
const trashTarget = "trash"
|
||||||
|
|
||||||
func (r *WebmailRepository) moveInto(
|
// MoveToTrash is what "delete" means in a mail client: the message goes to the
|
||||||
ctx context.Context, mailboxID, folderName string, uid uint32, target string,
|
// Trash folder and keeps its identity. Deleting from Trash itself is Expunge's
|
||||||
) (uint32, error) {
|
// job, so asking to move out of Trash reports port.ErrAlreadyInTrash.
|
||||||
tx, err := r.pool.Begin(ctx)
|
func (r *WebmailRepository) MoveToTrash(ctx context.Context, mailboxID, folderName string, uid uint32) (uint32, error) {
|
||||||
if err != nil {
|
return r.moveInto(ctx, mailboxID, folderName, uid, trashTarget)
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
defer tx.Rollback(ctx)
|
|
||||||
|
|
||||||
var messageID uuid.UUID
|
|
||||||
var sourceFolderID string
|
|
||||||
var wasUnread bool
|
|
||||||
err = tx.QueryRow(ctx, `
|
|
||||||
SELECT e.id, f.id::text,
|
|
||||||
NOT EXISTS (SELECT 1 FROM message_flags mf
|
|
||||||
WHERE mf.message_id = e.id
|
|
||||||
AND mf.received_at = e.received_at
|
|
||||||
AND mf.flag = '\Seen')
|
|
||||||
FROM email_messages e
|
|
||||||
JOIN folders f ON f.id = e.folder_id
|
|
||||||
WHERE e.mailbox_id = $1 AND `+folderCondition+`
|
|
||||||
AND e.uid = $3 AND e.expunged_at IS NULL
|
|
||||||
`, mailboxID, folderName, int64(uid)).Scan(&messageID, &sourceFolderID, &wasUnread)
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return 0, ErrMessageNotFound
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deleting something already in Trash has no further destination, so the
|
|
||||||
// caller is told to expunge instead of the message being moved onto itself.
|
|
||||||
var trashFolderID string
|
|
||||||
var trashUID int64
|
|
||||||
err = tx.QueryRow(ctx, `
|
|
||||||
SELECT id::text, uid_next FROM folders
|
|
||||||
WHERE mailbox_id = $1 AND (special_use = LOWER($2) OR LOWER(name) = LOWER($2))
|
|
||||||
ORDER BY special_use IS NOT NULL DESC
|
|
||||||
LIMIT 1
|
|
||||||
FOR UPDATE
|
|
||||||
`, mailboxID, target).Scan(&trashFolderID, &trashUID)
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return 0, port.ErrNoTrashFolder
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
if trashFolderID == sourceFolderID {
|
|
||||||
return 0, port.ErrAlreadyInTrash
|
|
||||||
}
|
|
||||||
|
|
||||||
var modseq int64
|
|
||||||
if err := tx.QueryRow(ctx, `
|
|
||||||
UPDATE folders SET uid_next = uid_next + 1, highest_modseq = highest_modseq + 1,
|
|
||||||
total_count = total_count + 1, unread_count = unread_count + $2
|
|
||||||
WHERE id = $1
|
|
||||||
RETURNING highest_modseq
|
|
||||||
`, trashFolderID, boolToInt(wasUnread)).Scan(&modseq); err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := tx.Exec(ctx, `
|
|
||||||
UPDATE email_messages SET folder_id = $2, uid = $3, modseq = $4
|
|
||||||
WHERE id = $1
|
|
||||||
`, messageID, trashFolderID, trashUID, modseq); err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
unreadDelta := 0
|
|
||||||
if wasUnread {
|
|
||||||
unreadDelta = -1
|
|
||||||
}
|
|
||||||
if _, err := tx.Exec(ctx, `
|
|
||||||
UPDATE folders
|
|
||||||
SET total_count = GREATEST(0, total_count - 1),
|
|
||||||
unread_count = GREATEST(0, unread_count + $2),
|
|
||||||
highest_modseq = highest_modseq + 1
|
|
||||||
WHERE id = $1
|
|
||||||
`, sourceFolderID, unreadDelta); err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Commit(ctx); err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return uint32(trashUID), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func boolToInt(b bool) int {
|
// MoveToFolder files one message into a named folder, matched by role or name.
|
||||||
if b {
|
func (r *WebmailRepository) MoveToFolder(ctx context.Context, mailboxID, folderName string, uid uint32, target string) (uint32, error) {
|
||||||
return 1
|
return r.moveInto(ctx, mailboxID, folderName, uid, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *WebmailRepository) moveInto(ctx context.Context, mailboxID, folderName string, uid uint32, target string) (uint32, error) {
|
||||||
|
sourceID, err := r.messageFolder(ctx, mailboxID, folderName)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
}
|
}
|
||||||
return 0
|
destID, err := r.store.ResolveFolder(ctx, mailboxID, target)
|
||||||
|
if errors.Is(err, port.ErrFolderMissing) {
|
||||||
|
if target == trashTarget {
|
||||||
|
return 0, port.ErrNoTrashFolder
|
||||||
|
}
|
||||||
|
return 0, port.ErrFolderMissing
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if destID == sourceID {
|
||||||
|
return 0, port.ErrAlreadyInTrash
|
||||||
|
}
|
||||||
|
return r.store.Move(ctx, sourceID, uid, destID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- folders the mailbox owner creates for themselves --------------------
|
// --- folders the mailbox owner creates for themselves --------------------
|
||||||
@@ -507,20 +234,12 @@ func (r *WebmailRepository) RenameFolder(ctx context.Context, mailboxID, from, t
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteFolder removes a folder and the mail filed in it.
|
// DeleteFolder removes a folder the owner made, and the mail filed in it. The
|
||||||
//
|
// messages are expunged (recoverable) and their bytes go back to the quota,
|
||||||
// The messages are expunged rather than deleted outright, matching what the
|
// both inside MailStore.
|
||||||
// rest of the schema does: the blobs stay referenced until the retention sweep,
|
|
||||||
// so a folder deleted by mistake is still recoverable from the database.
|
|
||||||
func (r *WebmailRepository) DeleteFolder(ctx context.Context, mailboxID, name string) error {
|
func (r *WebmailRepository) DeleteFolder(ctx context.Context, mailboxID, name string) error {
|
||||||
tx, err := r.pool.Begin(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer tx.Rollback(ctx)
|
|
||||||
|
|
||||||
var folderID string
|
var folderID string
|
||||||
err = tx.QueryRow(ctx, `
|
err := r.pool.QueryRow(ctx, `
|
||||||
SELECT id::text FROM folders
|
SELECT id::text FROM folders
|
||||||
WHERE mailbox_id = $1 AND LOWER(name) = LOWER($2) AND special_use IS NULL
|
WHERE mailbox_id = $1 AND LOWER(name) = LOWER($2) AND special_use IS NULL
|
||||||
`, mailboxID, name).Scan(&folderID)
|
`, mailboxID, name).Scan(&folderID)
|
||||||
@@ -530,16 +249,5 @@ func (r *WebmailRepository) DeleteFolder(ctx context.Context, mailboxID, name st
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
return r.store.DeleteFolder(ctx, folderID)
|
||||||
if _, err := tx.Exec(ctx, `
|
|
||||||
UPDATE email_messages SET expunged_at = NOW()
|
|
||||||
WHERE folder_id = $1 AND expunged_at IS NULL
|
|
||||||
`, folderID); err != nil {
|
|
||||||
return fmt.Errorf("expunge the folder's messages: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := tx.Exec(ctx, `DELETE FROM folders WHERE id = $1`, folderID); err != nil {
|
|
||||||
return fmt.Errorf("delete folder %q: %w", name, err)
|
|
||||||
}
|
|
||||||
return tx.Commit(ctx)
|
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user