| 528 |
528 |
|
.expect("create email");
|
| 529 |
529 |
|
assert_eq!(untracked.body_format, BodyFormat::Plain);
|
| 530 |
530 |
|
}
|
|
531 |
+ |
|
|
532 |
+ |
// --- The outbox ------------------------------------------------------------
|
|
533 |
+ |
//
|
|
534 |
+ |
// Ruled 2026-08-22 (goingson a3c76a24): sending is queueing, and send-later is
|
|
535 |
+ |
// a feature of the queue rather than a setting beside it. A queued message is a
|
|
536 |
+ |
// draft that has been committed to send, which is Eudora's Out mailbox and what
|
|
537 |
+ |
// keeps the drafts list working unchanged.
|
|
538 |
+ |
|
|
539 |
+ |
/// A saved draft, which is what everything below queues.
|
|
540 |
+ |
fn draft(repo: &SqliteEmailRepository, user_id: goingson_core::UserId) -> goingson_core::Email {
|
|
541 |
+ |
repo.save_draft(
|
|
542 |
+ |
goingson_core::EmailId::new(),
|
|
543 |
+ |
user_id,
|
|
544 |
+ |
"me@example.com",
|
|
545 |
+ |
"them@example.com",
|
|
546 |
+ |
None,
|
|
547 |
+ |
None,
|
|
548 |
+ |
"Subject",
|
|
549 |
+ |
"Body",
|
|
550 |
+ |
None,
|
|
551 |
+ |
None,
|
|
552 |
+ |
None,
|
|
553 |
+ |
None,
|
|
554 |
+ |
)
|
|
555 |
+ |
.expect("the draft saves")
|
|
556 |
+ |
}
|
|
557 |
+ |
|
|
558 |
+ |
#[test]
|
|
559 |
+ |
fn queueing_a_draft_puts_it_in_the_outbox_and_leaves_it_a_draft() {
|
|
560 |
+ |
let db = common::setup_test_db();
|
|
561 |
+ |
let user_id = common::create_test_user(&db);
|
|
562 |
+ |
let repo = SqliteEmailRepository::new(db);
|
|
563 |
+ |
let saved = draft(&repo, user_id);
|
|
564 |
+ |
|
|
565 |
+ |
// Not in the outbox until it is queued: saving is not sending.
|
|
566 |
+ |
assert!(repo.list_outbox(user_id).expect("read").is_empty());
|
|
567 |
+ |
|
|
568 |
+ |
let queued = repo
|
|
569 |
+ |
.queue_draft(saved.id, user_id, None)
|
|
570 |
+ |
.expect("queue")
|
|
571 |
+ |
.expect("the draft is there");
|
|
572 |
+ |
|
|
573 |
+ |
assert!(queued.is_queued());
|
|
574 |
+ |
assert!(
|
|
575 |
+ |
queued.is_draft,
|
|
576 |
+ |
"still a draft, which is what keeps the rest working"
|
|
577 |
+ |
);
|
|
578 |
+ |
assert!(
|
|
579 |
+ |
queued.send_after.is_none(),
|
|
580 |
+ |
"no instant means as soon as possible"
|
|
581 |
+ |
);
|
|
582 |
+ |
assert_eq!(repo.list_outbox(user_id).expect("read").len(), 1);
|
|
583 |
+ |
}
|
|
584 |
+ |
|
|
585 |
+ |
#[test]
|
|
586 |
+ |
fn send_later_is_the_whole_of_an_instant_on_the_row() {
|
|
587 |
+ |
let db = common::setup_test_db();
|
|
588 |
+ |
let user_id = common::create_test_user(&db);
|
|
589 |
+ |
let repo = SqliteEmailRepository::new(db);
|
|
590 |
+ |
|
|
591 |
+ |
let soon = draft(&repo, user_id);
|
|
592 |
+ |
let later = draft(&repo, user_id);
|
|
593 |
+ |
let now = Utc::now();
|
|
594 |
+ |
|
|
595 |
+ |
repo.queue_draft(soon.id, user_id, None).expect("queue");
|
|
596 |
+ |
repo.queue_draft(later.id, user_id, Some(now + Duration::hours(3)))
|
|
597 |
+ |
.expect("queue later");
|
|
598 |
+ |
|
|
599 |
+ |
// Both are in the outbox, because the outbox is what has been committed to
|
|
600 |
+ |
// send rather than what is going right now.
|
|
601 |
+ |
assert_eq!(repo.list_outbox(user_id).expect("read").len(), 2);
|
|
602 |
+ |
|
|
603 |
+ |
// Only one is due. This is the whole of send-later: the drainer asks the
|
|
604 |
+ |
// clock, and a message with an instant in the future is simply not in the
|
|
605 |
+ |
// answer.
|
|
606 |
+ |
let due = repo.list_due(user_id, now).expect("read due");
|
|
607 |
+ |
assert_eq!(due.len(), 1);
|
|
608 |
+ |
assert_eq!(due[0].id, soon.id);
|
|
609 |
+ |
|
|
610 |
+ |
// And it becomes due by time passing, with nothing armed or scheduled. A
|
|
611 |
+ |
// message written while the app was shut goes when the app comes back.
|
|
612 |
+ |
let due = repo
|
|
613 |
+ |
.list_due(user_id, now + Duration::hours(4))
|
|
614 |
+ |
.expect("read due");
|
|
615 |
+ |
assert_eq!(due.len(), 2);
|
|
616 |
+ |
}
|
|
617 |
+ |
|
|
618 |
+ |
#[test]
|
|
619 |
+ |
fn a_message_taken_back_out_is_an_ordinary_draft_again() {
|
|
620 |
+ |
let db = common::setup_test_db();
|
|
621 |
+ |
let user_id = common::create_test_user(&db);
|
|
622 |
+ |
let repo = SqliteEmailRepository::new(db);
|
|
623 |
+ |
let saved = draft(&repo, user_id);
|
|
624 |
+ |
|
|
625 |
+ |
repo.queue_draft(saved.id, user_id, Some(Utc::now() + Duration::hours(1)))
|
|
626 |
+ |
.expect("queue");
|
|
627 |
+ |
repo.record_send_failure(saved.id, user_id, "the server said no")
|
|
628 |
+ |
.expect("record");
|
|
629 |
+ |
|
|
630 |
+ |
let back = repo
|
|
631 |
+ |
.unqueue_draft(saved.id, user_id)
|
|
632 |
+ |
.expect("unqueue")
|
|
633 |
+ |
.expect("it is there");
|
|
634 |
+ |
|
|
635 |
+ |
assert!(!back.is_queued());
|
|
636 |
+ |
assert!(back.send_after.is_none(), "the schedule goes with it");
|
|
637 |
+ |
// The failure goes too: somebody who has taken a message back to edit it
|
|
638 |
+ |
// should not be looking at the error from before the edit.
|
|
639 |
+ |
assert!(back.send_error.is_none());
|
|
640 |
+ |
assert_eq!(back.send_attempts, 0);
|
|
641 |
+ |
assert!(repo.list_outbox(user_id).expect("read").is_empty());
|
|
642 |
+ |
}
|
|
643 |
+ |
|
|
644 |
+ |
#[test]
|
|
645 |
+ |
fn a_failed_send_stays_in_the_outbox_and_says_why() {
|
|
646 |
+ |
let db = common::setup_test_db();
|
|
647 |
+ |
let user_id = common::create_test_user(&db);
|
|
648 |
+ |
let repo = SqliteEmailRepository::new(db);
|
|
649 |
+ |
let saved = draft(&repo, user_id);
|
|
650 |
+ |
repo.queue_draft(saved.id, user_id, None).expect("queue");
|
|
651 |
+ |
|
|
652 |
+ |
repo.record_send_failure(saved.id, user_id, "connection refused")
|
|
653 |
+ |
.expect("record");
|
|
654 |
+ |
repo.record_send_failure(saved.id, user_id, "connection refused")
|
|
655 |
+ |
.expect("record again");
|
|
656 |
+ |
|
|
657 |
+ |
let still = repo.list_outbox(user_id).expect("read");
|
|
658 |
+ |
assert_eq!(still.len(), 1, "a failure does not drop the message");
|
|
659 |
+ |
assert_eq!(still[0].send_attempts, 2);
|
|
660 |
+ |
assert_eq!(still[0].send_error.as_deref(), Some("connection refused"));
|
|
661 |
+ |
// Still due, so the next tick tries again. The backoff is the drainer's,
|
|
662 |
+ |
// not the store's.
|
|
663 |
+ |
assert_eq!(repo.list_due(user_id, Utc::now()).expect("due").len(), 1);
|
|
664 |
+ |
}
|
|
665 |
+ |
|
|
666 |
+ |
#[test]
|
|
667 |
+ |
fn a_received_message_cannot_be_queued() {
|
|
668 |
+ |
// Queueing something that is not a draft would be the app offering to send
|
|
669 |
+ |
// a message somebody else sent it. Refused by the write not matching, so
|
|
670 |
+ |
// there is no window between a check and an update.
|
|
671 |
+ |
let db = common::setup_test_db();
|
|
672 |
+ |
let user_id = common::create_test_user(&db);
|
|
673 |
+ |
let repo = SqliteEmailRepository::new(db);
|
|
674 |
+ |
|
|
675 |
+ |
let received = repo
|
|
676 |
+ |
.create(
|
|
677 |
+ |
user_id,
|
|
678 |
+ |
NewEmail {
|
|
679 |
+ |
project_id: None,
|
|
680 |
+ |
from_address: "them@example.com".to_string(),
|
|
681 |
+ |
to_address: "me@example.com".to_string(),
|
|
682 |
+ |
subject: "Incoming".to_string(),
|
|
683 |
+ |
body: "Hello".to_string(),
|
|
684 |
+ |
is_read: false,
|
|
685 |
+ |
received_at: Some(Utc::now()),
|
|
686 |
+ |
},
|
|
687 |
+ |
)
|
|
688 |
+ |
.expect("create");
|
|
689 |
+ |
|
|
690 |
+ |
assert!(
|
|
691 |
+ |
repo.queue_draft(received.id, user_id, None)
|
|
692 |
+ |
.expect("ask")
|
|
693 |
+ |
.is_none()
|
|
694 |
+ |
);
|
|
695 |
+ |
assert!(repo.list_outbox(user_id).expect("read").is_empty());
|
|
696 |
+ |
}
|
|
697 |
+ |
|
|
698 |
+ |
#[test]
|
|
699 |
+ |
fn the_outbox_is_one_persons_own() {
|
|
700 |
+ |
let db = common::setup_test_db();
|
|
701 |
+ |
let user_id = common::create_test_user(&db);
|
|
702 |
+ |
let repo = SqliteEmailRepository::new(db.clone());
|
|
703 |
+ |
let saved = draft(&repo, user_id);
|
|
704 |
+ |
repo.queue_draft(saved.id, user_id, None).expect("queue");
|
|
705 |
+ |
|
|
706 |
+ |
let other = common::create_test_user(&db);
|
|
707 |
+ |
assert!(repo.list_outbox(other).expect("read").is_empty());
|
|
708 |
+ |
assert!(
|
|
709 |
+ |
repo.queue_draft(saved.id, other, None)
|
|
710 |
+ |
.expect("ask")
|
|
711 |
+ |
.is_none()
|
|
712 |
+ |
);
|
|
713 |
+ |
assert!(repo.unqueue_draft(saved.id, other).expect("ask").is_none());
|
|
714 |
+ |
}
|